1
11import { afterAll, beforeAll, describe, expect, it } from "vitest";
12import { createHash } from "node:crypto";
13import { createTestApp, type TestAppHandle } from "./helpers/api";
14import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed";
15
16
17const sha256hex = (v: string): string => createHash("sha256").update(v).digest("hex");
18
19let app: TestAppHandle;
20
21beforeAll(async () => {
22 app = await createTestApp();
23}, 180_000);
24afterAll(async () => {
25 await app?.close();
26});
27
28const json = async (res: Response): Promise<Record<string, unknown>> =>
29 (await res.json().catch(() => ({}))) as Record<string, unknown>;
30
31describe("Workspace integration", () => {
32 it("create: owner membership + audit event", async () => {
33 const owner = await createUser(app.request, { name: "WS Owner" });
34 const auditBefore = await countAuditEvents(app.prisma, {
35 action: "workspace.created",
36 });
37 const { workspaceId } = await createWorkspace(
38 app.request,
39 authHeaders(owner.accessToken!),
40 "Audit WS",
41 );
42 const auditAfter = await countAuditEvents(app.prisma, {
43 action: "workspace.created",
44 });
45 expect(auditAfter).toBe(auditBefore + 1);
46
47 const membership = await app.prisma.workspaceMember.findUnique({
48 where: { workspaceId_userId: { workspaceId, userId: owner.userId } },
49 });
50 expect(membership?.role).toBe("OWNER");
51
52 const ws = await app.prisma.workspace.findUnique({ where: { id: workspaceId } });
53 expect(ws?.name).toBe("Audit WS");
54 expect(ws?.slug).toBeTruthy();
55 });
56
57 it("create: requires auth", async () => {
58 const res = await app.request("POST", "/api/v1/workspaces", {
59 body: { name: "Nope" },
60 });
61 expect(res.status).toBe(401);
62 });
63
64 it("rename: OWNER can, MEMBER cannot", async () => {
65 const owner = await createUser(app.request, { name: "Rename Owner" });
66 const { workspaceId } = await createWorkspace(
67 app.request,
68 authHeaders(owner.accessToken!),
69 "Rename Me",
70 );
71
72
73
74
75 const member = await createUser(app.request, { name: "Rename Member" });
76 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER");
77
78
79 const ok = await app.request("PATCH", `/api/v1/workspaces/${workspaceId}`, {
80 headers: authHeaders(owner.accessToken!),
81 body: { name: "Renamed OK" },
82 });
83 expect(ok.status).toBe(200);
84
85
86 const denied = await app.request("PATCH", `/api/v1/workspaces/${workspaceId}`, {
87 headers: authHeaders(member.accessToken!),
88 body: { name: "Member Hax" },
89 });
90 expect(denied.status).toBe(403);
91
92
93 const ws = await app.prisma.workspace.findUnique({ where: { id: workspaceId } });
94 expect(ws?.name).toBe("Renamed OK");
95 });
96
97 it("tenant isolation: outsider gets 403/404 on every read endpoint", async () => {
98 const owner = await createUser(app.request, { name: "Iso Owner" });
99 const { workspaceId } = await createWorkspace(
100 app.request,
101 authHeaders(owner.accessToken!),
102 "Secret WS",
103 );
104
105 const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
106 headers: authHeaders(owner.accessToken!),
107 body: { title: "Secret Page" },
108 });
109 expect(pg.status).toBe(201);
110 const pageId = ((await json(pg)) as { id: string }).id;
111
112 const outsider = await createUser(app.request, { name: "Iso Outsider" });
113 const h = authHeaders(outsider.accessToken!);
114
115 const getWs = await app.request("GET", `/api/v1/workspaces/${workspaceId}`, { headers: h });
116 expect([403, 404]).toContain(getWs.status);
117
118 const listPages = await app.request("GET", `/api/v1/workspaces/${workspaceId}/pages`, {
119 headers: h,
120 });
121 expect([403, 404]).toContain(listPages.status);
122
123 const getPage = await app.request("GET", `/api/v1/pages/${pageId}`, { headers: h });
124 expect([403, 404]).toContain(getPage.status);
125
126 const listMembers = await app.request("GET", `/api/v1/workspaces/${workspaceId}/members`, {
127 headers: h,
128 });
129 expect([403, 404]).toContain(listMembers.status);
130 });
131
132 it("tenant isolation: outsider cannot create pages or delete the workspace", async () => {
133 const owner = await createUser(app.request, { name: "Mut Owner" });
134 const { workspaceId } = await createWorkspace(
135 app.request,
136 authHeaders(owner.accessToken!),
137 "Mut WS",
138 );
139 const outsider = await createUser(app.request, { name: "Mut Outsider" });
140 const h = authHeaders(outsider.accessToken!);
141
142 const createPage = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
143 headers: h,
144 body: { title: "Intruder Page" },
145 });
146 expect([403, 404]).toContain(createPage.status);
147
148 const del = await app.request("DELETE", `/api/v1/workspaces/${workspaceId}`, {
149 headers: h,
150 });
151 expect([403, 404]).toContain(del.status);
152
153
154 const ws = await app.prisma.workspace.findUnique({ where: { id: workspaceId } });
155 expect(ws).not.toBeNull();
156 expect(ws?.deletedAt).toBeNull();
157 });
158
159 it("invite: wrong-acceptor email cannot accept the invite", async () => {
160 const owner = await createUser(app.request, { name: "Invite Owner" });
161 const { workspaceId } = await createWorkspace(
162 app.request,
163 authHeaders(owner.accessToken!),
164 "Invite WS",
165 );
166 const target = await createUser(app.request, { name: "Invite Target" });
167 const other = await createUser(app.request, { name: "Invite Other" });
168
169
170
171
172
173
174
175 const inviteToken = `inv_test_${Math.random().toString(36).slice(2)}`;
176 const invitation = await app.prisma.invitation.create({
177 data: {
178 workspaceId,
179 email: target.email,
180 role: "MEMBER",
181 invitedById: owner.userId,
182 tokenHash: sha256hex(inviteToken),
183 status: "PENDING",
184 expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000),
185 },
186 });
187
188
189
190 const wrong = await app.request(
191 "POST",
192 `/api/v1/workspaces/${workspaceId}/members/invites/${invitation.id}/accept?token=${encodeURIComponent(inviteToken)}`,
193 { headers: authHeaders(other.accessToken!) },
194 );
195 expect([400, 403, 404]).toContain(wrong.status);
196
197
198 const right = await app.request(
199 "POST",
200 `/api/v1/workspaces/${workspaceId}/members/invites/${invitation.id}/accept?token=${encodeURIComponent(inviteToken)}`,
201 { headers: authHeaders(target.accessToken!) },
202 );
203 expect([200, 201]).toContain(right.status);
204 const membership = await app.prisma.workspaceMember.findUnique({
205 where: { workspaceId_userId: { workspaceId, userId: target.userId } },
206 });
207 expect(membership?.role).toBe("MEMBER");
208 });
209
210 it("delete: OWNER deletes (soft), subsequent reads 404", async () => {
211 const owner = await createUser(app.request, { name: "Del Owner" });
212 const { workspaceId } = await createWorkspace(
213 app.request,
214 authHeaders(owner.accessToken!),
215 "Del WS",
216 );
217 const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
218 headers: authHeaders(owner.accessToken!),
219 body: { title: "Cascade Page" },
220 });
221 expect(pg.status).toBe(201);
222
223 const del = await app.request("DELETE", `/api/v1/workspaces/${workspaceId}`, {
224 headers: authHeaders(owner.accessToken!),
225 });
226 expect([200, 204]).toContain(del.status);
227
228 const ws = await app.prisma.workspace.findUnique({ where: { id: workspaceId } });
229 expect(ws?.deletedAt).not.toBeNull();
230
231 const after = await app.request("GET", `/api/v1/workspaces/${workspaceId}`, {
232 headers: authHeaders(owner.accessToken!),
233 });
234 expect([403, 404]).toContain(after.status);
235 });
236
237 it("member list: shows roles for actual members only", async () => {
238 const owner = await createUser(app.request, { name: "List Owner" });
239 const { workspaceId } = await createWorkspace(
240 app.request,
241 authHeaders(owner.accessToken!),
242 "List WS",
243 );
244 const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/members`, {
245 headers: authHeaders(owner.accessToken!),
246 });
247 expect(res.status).toBe(200);
248 const body = await json(res);
249 const members = (body.members as { role: string }[] | undefined) ?? [];
250 expect(members.length).toBe(1);
251 expect(members[0].role).toBe("OWNER");
252 });
253});
254