1
23import { afterAll, beforeAll, describe, expect, it } from "vitest";
24import { createTestApp, type TestAppHandle } from "./helpers/api";
25import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed";
26
27let app: TestAppHandle;
28
29beforeAll(async () => {
30 app = await createTestApp();
31}, 180_000);
32afterAll(async () => {
33 await app?.close();
34});
35
36const json = async (res: Response): Promise<Record<string, unknown>> =>
37 (await res.json().catch(() => ({}))) as Record<string, unknown>;
38
39
43async function addMember(
44 _ownerToken: string,
45 workspaceId: string,
46 role: "MEMBER" | "GUEST" | "ADMIN",
47): Promise<{ userId: string; email: string; accessToken: string; name: string }> {
48 const user = await createUser(app.request, { name: `Perm ${role}` });
49 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, user.email, role);
50 return user;
51}
52
53
54async function grant(
55 ownerToken: string,
56 workspaceId: string,
57 body: Record<string, unknown>,
58): Promise<string> {
59 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/permissions`, {
60 headers: authHeaders(ownerToken),
61 body,
62 });
63 expect(
64 [200, 201],
65 `grant failed (${res.status}): ${await res.text()}`,
66 ).toContain(res.status);
67 const grantBody = await json(res);
68 return (
69 (grantBody.permission as { id: string } | undefined)?.id ??
70 (grantBody.grant as { id: string } | undefined)?.id ??
71 (grantBody.id as string | undefined) ??
72 ""
73 );
74}
75
76async function revoke(ownerToken: string, grantId: string): Promise<void> {
77 const res = await app.request("DELETE", `/api/v1/permissions/${grantId}`, {
78 headers: authHeaders(ownerToken),
79 });
80 expect([200, 204]).toContain(res.status);
81}
82
83
84async function newPage(ownerToken: string, workspaceId: string, title: string): Promise<string> {
85 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
86 headers: authHeaders(ownerToken),
87 body: { title },
88 });
89 expect(res.status).toBe(201);
90
91 return ((await json(res)) as { id: string }).id;
92}
93
94async function setupOwnerWorkspace(): Promise<{
95 ownerToken: string;
96 workspaceId: string;
97 ownerId: string;
98}> {
99 const owner = await createUser(app.request, { name: "Perm Owner" });
100 const { workspaceId } = await createWorkspace(
101 app.request,
102 authHeaders(owner.accessToken!),
103 "Perm WS",
104 );
105 return { ownerToken: owner.accessToken!, workspaceId, ownerId: owner.userId };
106}
107
108describe("Permissions integration", () => {
109 it("grant matrix VIEW: the grantee can READ the page but not edit it", async () => {
110 const { ownerToken, workspaceId } = await setupOwnerWorkspace();
111 const guest = await addMember(ownerToken, workspaceId, "GUEST");
112 const pageId = await newPage(ownerToken, workspaceId, "View Only");
113
114
115 const baseEdit = await app.request("PATCH", `/api/v1/pages/${pageId}`, {
116 headers: authHeaders(guest.accessToken!),
117 body: { title: "Guest sneak" },
118 });
119 expect(baseEdit.status).toBe(403);
120
121
122 await grant(ownerToken, workspaceId, {
123 granteeType: "user",
124 granteeId: guest.userId,
125 resourceType: "page",
126 resourceId: pageId,
127 level: "VIEW",
128 });
129
130 const read = await app.request("GET", `/api/v1/pages/${pageId}`, {
131 headers: authHeaders(guest.accessToken!),
132 });
133 expect(read.status).toBe(200);
134
135 const edit = await app.request("PATCH", `/api/v1/pages/${pageId}`, {
136 headers: authHeaders(guest.accessToken!),
137 body: { title: "Guest sneak 2" },
138 });
139 expect(edit.status).toBe(403);
140 });
141
142 it("grant matrix EDIT: the grantee can edit content but not manage grants", async () => {
143 const { ownerToken, workspaceId } = await setupOwnerWorkspace();
144 const guest = await addMember(ownerToken, workspaceId, "GUEST");
145 const pageId = await newPage(ownerToken, workspaceId, "Editable");
146
147 await grant(ownerToken, workspaceId, {
148 granteeType: "user",
149 granteeId: guest.userId,
150 resourceType: "page",
151 resourceId: pageId,
152 level: "EDIT",
153 });
154
155
156 const edit = await app.request("PATCH", `/api/v1/pages/${pageId}`, {
157 headers: authHeaders(guest.accessToken!),
158 body: { title: "Edited by grantee" },
159 });
160 expect(edit.status).toBe(200);
161 const page = await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } });
162 expect(page.title).toBe("Edited by grantee");
163
164
165 const manage = await app.request(
166 "POST",
167 `/api/v1/workspaces/${workspaceId}/permissions`,
168 {
169 headers: authHeaders(guest.accessToken!),
170 body: {
171 granteeType: "user",
172 granteeId: guest.userId,
173 resourceType: "page",
174 resourceId: pageId,
175 level: "FULL_ACCESS",
176 },
177 },
178 );
179 expect(manage.status).toBe(403);
180 });
181
182 it("grant matrix FULL_ACCESS: the grantee can manage grants on that resource", async () => {
183 const { ownerToken, workspaceId } = await setupOwnerWorkspace();
184 const guest = await addMember(ownerToken, workspaceId, "GUEST");
185 const pageId = await newPage(ownerToken, workspaceId, "Full Control");
186
187 await grant(ownerToken, workspaceId, {
188 granteeType: "user",
189 granteeId: guest.userId,
190 resourceType: "page",
191 resourceId: pageId,
192 level: "FULL_ACCESS",
193 });
194
195
196 const other = await addMember(ownerToken, workspaceId, "GUEST");
197 const create = await app.request(
198 "POST",
199 `/api/v1/workspaces/${workspaceId}/permissions`,
200 {
201 headers: authHeaders(guest.accessToken!),
202 body: {
203 granteeType: "user",
204 granteeId: other.userId,
205 resourceType: "page",
206 resourceId: pageId,
207 level: "VIEW",
208 },
209 },
210 );
211 expect(create.status).toBe(201);
212 });
213
214 it("inheritance: workspace VIEW grant reaches a child page; page EDIT grant elevates only that page", async () => {
215 const { ownerToken, workspaceId, ownerId } = await setupOwnerWorkspace();
216
217
218 const member = await addMember(ownerToken, workspaceId, "MEMBER");
219 const parent = await newPage(ownerToken, workspaceId, "Parent");
220 const childA = await newPage(ownerToken, workspaceId, "Child A");
221 const childB = await newPage(ownerToken, workspaceId, "Child B");
222 void parent;
223 void ownerId;
224
225
226 await grant(ownerToken, workspaceId, {
227 granteeType: "user",
228 granteeId: member.userId,
229 resourceType: "workspace",
230 resourceId: workspaceId,
231 level: "VIEW",
232 });
233
234 const readA = await app.request("GET", `/api/v1/pages/${childA}`, {
235 headers: authHeaders(member.accessToken!),
236 });
237 const readB = await app.request("GET", `/api/v1/pages/${childB}`, {
238 headers: authHeaders(member.accessToken!),
239 });
240 expect(readA.status).toBe(200);
241 expect(readB.status).toBe(200);
242
243
244 const wsGrant = await app.prisma.permission.findFirst({
245 where: {
246 workspaceId,
247 granteeId: member.userId,
248 resourceType: "workspace",
249 level: "VIEW",
250 },
251 });
252 const pageGrantId = await grant(ownerToken, workspaceId, {
253 granteeType: "user",
254 granteeId: member.userId,
255 resourceType: "page",
256 resourceId: childA,
257 level: "EDIT",
258 inheritsFrom: wsGrant?.id ?? null,
259 });
260 expect(pageGrantId).toBeTruthy();
261
262
263 const editA = await app.request("PATCH", `/api/v1/pages/${childA}`, {
264 headers: authHeaders(member.accessToken!),
265 body: { title: "Child A edited" },
266 });
267 expect(editA.status).toBe(200);
268
269
270 const editB = await app.request("PATCH", `/api/v1/pages/${childB}`, {
271 headers: authHeaders(member.accessToken!),
272 body: { title: "Child B edited" },
273 });
274 expect(editB.status).toBe(403);
275
276
277 const stored = await app.prisma.permission.findUniqueOrThrow({
278 where: { id: pageGrantId },
279 });
280 expect(stored.level).toBe("EDIT");
281 expect(stored.inheritsFrom).toBe(wsGrant?.id ?? null);
282 });
283
284 it("explicit NONE: denies even where the role would otherwise allow", async () => {
285 const { ownerToken, workspaceId } = await setupOwnerWorkspace();
286
287 const member = await addMember(ownerToken, workspaceId, "MEMBER");
288 const pageId = await newPage(ownerToken, workspaceId, "Locked For Member");
289
290 await grant(ownerToken, workspaceId, {
291 granteeType: "user",
292 granteeId: member.userId,
293 resourceType: "page",
294 resourceId: pageId,
295 level: "NONE",
296 });
297
298 const edit = await app.request("PATCH", `/api/v1/pages/${pageId}`, {
299 headers: authHeaders(member.accessToken!),
300 body: { title: "Member override" },
301 });
302 expect(edit.status).toBe(403);
303 });
304
305 it("revoke: removing a grant restores the role-default (denied) state", async () => {
306 const { ownerToken, workspaceId } = await setupOwnerWorkspace();
307 const guest = await addMember(ownerToken, workspaceId, "GUEST");
308 const pageId = await newPage(ownerToken, workspaceId, "Revoke Me");
309
310 const grantId = await grant(ownerToken, workspaceId, {
311 granteeType: "user",
312 granteeId: guest.userId,
313 resourceType: "page",
314 resourceId: pageId,
315 level: "EDIT",
316 });
317
318
319 const allowed = await app.request("PATCH", `/api/v1/pages/${pageId}`, {
320 headers: authHeaders(guest.accessToken!),
321 body: { title: "While granted" },
322 });
323 expect(allowed.status).toBe(200);
324
325
326 await revoke(ownerToken, grantId);
327 const gone = await app.prisma.permission.findUnique({ where: { id: grantId } });
328 expect(gone).toBeNull();
329
330
331 const denied = await app.request("PATCH", `/api/v1/pages/${pageId}`, {
332 headers: authHeaders(guest.accessToken!),
333 body: { title: "After revoke" },
334 });
335 expect(denied.status).toBe(403);
336 });
337
338 it("audit: creating and revoking a grant are audited as permission.changed", async () => {
339 const { ownerToken, workspaceId, ownerId } = await setupOwnerWorkspace();
340 const guest = await addMember(ownerToken, workspaceId, "GUEST");
341 const pageId = await newPage(ownerToken, workspaceId, "Audited");
342
343 const before = await countAuditEvents(app.prisma, {
344 action: "permission.changed",
345 actorId: ownerId,
346 });
347
348 const grantId = await grant(ownerToken, workspaceId, {
349 granteeType: "user",
350 granteeId: guest.userId,
351 resourceType: "page",
352 resourceId: pageId,
353 level: "EDIT",
354 });
355 await revoke(ownerToken, grantId);
356
357 const after = await countAuditEvents(app.prisma, {
358 action: "permission.changed",
359 actorId: ownerId,
360 });
361 expect(after).toBe(before + 2);
362 });
363
364 it("authorization: an outsider cannot list or create grants in a foreign workspace", async () => {
365 const { ownerToken, workspaceId } = await setupOwnerWorkspace();
366 const outsider = await createUser(app.request, { name: "Perm Outsider" });
367 const oh = authHeaders(outsider.accessToken!);
368
369 const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/permissions`, {
370 headers: oh,
371 });
372 expect([403, 404]).toContain(list.status);
373
374 const create = await app.request("POST", `/api/v1/workspaces/${workspaceId}/permissions`, {
375 headers: oh,
376 body: {
377 granteeType: "user",
378 granteeId: outsider.userId,
379 resourceType: "workspace",
380 resourceId: workspaceId,
381 level: "FULL_ACCESS",
382 },
383 });
384 expect([403, 404]).toContain(create.status);
385 });
386});
387