Skip to content
File

Blob: tests/worker/lib/permission.workers.test.ts

typescript427 lines
1import { beforeEach, describe, expect, it } from "vitest";
2 
3import {
4 canAccessPage,
5 canAccessPages,
6 resolvePageAccessLevels,
7 resolvePrincipal,
8 toResolvedViewerContext,
9} from "@/worker/lib/permissions";
10import { getDb, resetD1Tables } from "@tests/worker/helpers/db";
11import { TEST_TIMESTAMP } from "@tests/worker/helpers/fixtures";
12import { seedMembership, seedPage, seedPageShare, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
13 
14describe("worker permissions (real D1)", () => {
15 beforeEach(async () => {
16 await resetD1Tables();
17 });
18 
19 describe("resolvePrincipal", () => {
20 it("returns link principal on shared surface even when the user is a full member", async () => {
21 const owner = await seedUser();
22 const member = await seedUser();
23 const ws = await seedWorkspace({ owner_id: owner.id });
24 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
25 
26 const resolved = await resolvePrincipal(getDb(), { id: member.id }, ws.id, {
27 surface: "shared",
28 shareToken: "share-token",
29 });
30 
31 expect(resolved).toEqual({
32 principal: { type: "link", token: "share-token" },
33 workspaceRole: null,
34 });
35 });
36 
37 it("returns user principal with writer role on canonical surface for members", async () => {
38 const owner = await seedUser();
39 const member = await seedUser();
40 const ws = await seedWorkspace({ owner_id: owner.id });
41 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
42 
43 const resolved = await resolvePrincipal(getDb(), { id: member.id }, ws.id, { surface: "canonical" });
44 
45 expect(resolved).toEqual({
46 principal: { type: "user", userId: member.id },
47 workspaceRole: "member",
48 });
49 });
50 
51 it("prefers share token for guests on canonical surface but still surfaces guest role", async () => {
52 const owner = await seedUser();
53 const guest = await seedUser();
54 const ws = await seedWorkspace({ owner_id: owner.id });
55 await seedMembership({ user_id: guest.id, workspace_id: ws.id, role: "guest" });
56 
57 const resolved = await resolvePrincipal(getDb(), { id: guest.id }, ws.id, {
58 surface: "canonical",
59 shareToken: "share-token",
60 });
61 
62 expect(resolved).toEqual({
63 principal: { type: "link", token: "share-token" },
64 workspaceRole: "guest",
65 });
66 });
67 
68 it("returns user principal with guest role on canonical surface without share token", async () => {
69 const owner = await seedUser();
70 const guest = await seedUser();
71 const ws = await seedWorkspace({ owner_id: owner.id });
72 await seedMembership({ user_id: guest.id, workspace_id: ws.id, role: "guest" });
73 
74 const resolved = await resolvePrincipal(getDb(), { id: guest.id }, ws.id, { surface: "canonical" });
75 
76 expect(resolved).toEqual({
77 principal: { type: "user", userId: guest.id },
78 workspaceRole: "guest",
79 });
80 });
81 
82 it("returns null workspace role for an authenticated non-member on canonical surface", async () => {
83 const owner = await seedUser();
84 const outsider = await seedUser();
85 const ws = await seedWorkspace({ owner_id: owner.id });
86 
87 const resolved = await resolvePrincipal(getDb(), { id: outsider.id }, ws.id, { surface: "canonical" });
88 
89 expect(resolved).toEqual({
90 principal: { type: "user", userId: outsider.id },
91 workspaceRole: null,
92 });
93 });
94 
95 it("returns anonymous link principal when no user is present on shared surface", async () => {
96 const owner = await seedUser();
97 const ws = await seedWorkspace({ owner_id: owner.id });
98 
99 const resolved = await resolvePrincipal(getDb(), null, ws.id, {
100 surface: "shared",
101 shareToken: "share-token",
102 });
103 
104 expect(resolved).toEqual({
105 principal: { type: "link", token: "share-token" },
106 workspaceRole: null,
107 });
108 });
109 
110 it("returns null when no user and no share token", async () => {
111 const owner = await seedUser();
112 const ws = await seedWorkspace({ owner_id: owner.id });
113 
114 const resolved = await resolvePrincipal(getDb(), null, ws.id, { surface: "canonical" });
115 
116 expect(resolved).toBeNull();
117 });
118 });
119 
120 describe("toResolvedViewerContext", () => {
121 it("marks shared surface as link-scoped with null workspace role even for member principals", () => {
122 const viewer = toResolvedViewerContext(
123 { principal: { type: "link", token: "tok" }, workspaceRole: "member" },
124 "slug",
125 "shared",
126 );
127 expect(viewer).toEqual({
128 access_mode: "shared",
129 principal_type: "link",
130 route_kind: "shared",
131 workspace_slug: null,
132 workspace_role: null,
133 });
134 });
135 
136 it("serializes canonical viewer context with workspace slug and writer role", () => {
137 const viewer = toResolvedViewerContext(
138 { principal: { type: "user", userId: "u1" }, workspaceRole: "member" },
139 "slug",
140 "canonical",
141 );
142 expect(viewer).toEqual({
143 access_mode: "member",
144 principal_type: "user",
145 route_kind: "canonical",
146 workspace_slug: "slug",
147 workspace_role: "member",
148 });
149 });
150 
151 it("serializes canonical guest as access_mode member with guest workspace role", () => {
152 const viewer = toResolvedViewerContext(
153 { principal: { type: "user", userId: "u1" }, workspaceRole: "guest" },
154 "slug",
155 "canonical",
156 );
157 expect(viewer).toEqual({
158 access_mode: "member",
159 principal_type: "user",
160 route_kind: "canonical",
161 workspace_slug: "slug",
162 workspace_role: "guest",
163 });
164 });
165 
166 it("serializes canonical non-member as access_mode shared with null workspace role", () => {
167 const viewer = toResolvedViewerContext(
168 { principal: { type: "user", userId: "u1" }, workspaceRole: null },
169 "slug",
170 "canonical",
171 );
172 expect(viewer).toEqual({
173 access_mode: "shared",
174 principal_type: "user",
175 route_kind: "canonical",
176 workspace_slug: "slug",
177 workspace_role: null,
178 });
179 });
180 });
181 
182 describe("resolvePageAccessLevels + canAccessPage(s)", () => {
183 it("short-circuits full workspace members to edit on every requested page", async () => {
184 const owner = await seedUser();
185 const member = await seedUser();
186 const ws = await seedWorkspace({ owner_id: owner.id });
187 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
188 const pageA = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "A" });
189 const pageB = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "B" });
190 
191 const levels = await resolvePageAccessLevels(
192 getDb(),
193 { type: "user", userId: member.id },
194 [pageA.id, pageB.id],
195 ws.id,
196 );
197 
198 expect(levels).toEqual(
199 new Map([
200 [pageA.id, "edit"],
201 [pageB.id, "edit"],
202 ]),
203 );
204 });
205 
206 it("maps share grants to per-page access levels for guests and non-members", async () => {
207 const owner = await seedUser();
208 const guest = await seedUser();
209 const ws = await seedWorkspace({ owner_id: owner.id });
210 await seedMembership({ user_id: guest.id, workspace_id: ws.id, role: "guest" });
211 const pageA = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "A" });
212 const pageB = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "B" });
213 const pageC = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "C" });
214 await seedPageShare({
215 page_id: pageA.id,
216 created_by: owner.id,
217 grantee_type: "user",
218 grantee_id: guest.id,
219 permission: "edit",
220 });
221 await seedPageShare({
222 page_id: pageB.id,
223 created_by: owner.id,
224 grantee_type: "user",
225 grantee_id: guest.id,
226 permission: "view",
227 });
228 
229 const levels = await resolvePageAccessLevels(
230 getDb(),
231 { type: "user", userId: guest.id },
232 [pageA.id, pageB.id, pageC.id],
233 ws.id,
234 );
235 
236 expect(levels).toEqual(
237 new Map([
238 [pageA.id, "edit"],
239 [pageB.id, "view"],
240 [pageC.id, "none"],
241 ]),
242 );
243 });
244 
245 it("converts resolved levels into booleans for batch view and edit checks", async () => {
246 const owner = await seedUser();
247 const outsider = await seedUser();
248 const ws = await seedWorkspace({ owner_id: owner.id });
249 const pageEdit = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "EditPage" });
250 const pageView = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "ViewPage" });
251 const pageNone = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "NonePage" });
252 await seedPageShare({
253 page_id: pageEdit.id,
254 created_by: owner.id,
255 grantee_type: "user",
256 grantee_id: outsider.id,
257 permission: "edit",
258 });
259 await seedPageShare({
260 page_id: pageView.id,
261 created_by: owner.id,
262 grantee_type: "user",
263 grantee_id: outsider.id,
264 permission: "view",
265 });
266 
267 const viewResults = await canAccessPages(
268 getDb(),
269 { type: "user", userId: outsider.id },
270 [pageEdit.id, pageView.id, pageNone.id],
271 ws.id,
272 "view",
273 );
274 const editResults = await canAccessPages(
275 getDb(),
276 { type: "user", userId: outsider.id },
277 [pageEdit.id, pageView.id, pageNone.id],
278 ws.id,
279 "edit",
280 );
281 
282 expect(viewResults).toEqual(
283 new Map([
284 [pageEdit.id, true],
285 [pageView.id, true],
286 [pageNone.id, false],
287 ]),
288 );
289 expect(editResults).toEqual(
290 new Map([
291 [pageEdit.id, true],
292 [pageView.id, false],
293 [pageNone.id, false],
294 ]),
295 );
296 });
297 
298 it("uses the same resolver path for single-page checks", async () => {
299 const owner = await seedUser();
300 const outsider = await seedUser();
301 const ws = await seedWorkspace({ owner_id: owner.id });
302 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Single" });
303 await seedPageShare({
304 page_id: page.id,
305 created_by: owner.id,
306 grantee_type: "user",
307 grantee_id: outsider.id,
308 permission: "view",
309 });
310 
311 await expect(canAccessPage(getDb(), { type: "user", userId: outsider.id }, page.id, ws.id, "view")).resolves.toBe(
312 true,
313 );
314 await expect(canAccessPage(getDb(), { type: "user", userId: outsider.id }, page.id, ws.id, "edit")).resolves.toBe(
315 false,
316 );
317 });
318 
319 it("skips membership checks for link principals and resolves access from shares only", async () => {
320 const owner = await seedUser();
321 const ws = await seedWorkspace({ owner_id: owner.id });
322 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Link" });
323 await seedPageShare({
324 page_id: page.id,
325 created_by: owner.id,
326 grantee_type: "link",
327 grantee_id: null,
328 link_token: "link-principal-tok",
329 permission: "edit",
330 });
331 
332 const levels = await resolvePageAccessLevels(
333 getDb(),
334 { type: "link", token: "link-principal-tok" },
335 [page.id],
336 ws.id,
337 );
338 
339 expect(levels).toEqual(new Map([[page.id, "edit"]]));
340 });
341 });
342 
343 describe("archived ancestor inheritance", () => {
344 it("blocks share inheritance through an archived ancestor", async () => {
345 const owner = await seedUser();
346 const grantee = await seedUser();
347 const ws = await seedWorkspace({ owner_id: owner.id });
348 const parent = await seedPage({
349 workspace_id: ws.id,
350 created_by: owner.id,
351 title: "Archived Parent",
352 archived_at: TEST_TIMESTAMP,
353 });
354 const child = await seedPage({
355 workspace_id: ws.id,
356 created_by: owner.id,
357 parent_id: parent.id,
358 title: "Live Child",
359 });
360 await seedPageShare({
361 page_id: parent.id,
362 created_by: owner.id,
363 grantee_type: "user",
364 grantee_id: grantee.id,
365 permission: "edit",
366 });
367 
368 const levels = await resolvePageAccessLevels(getDb(), { type: "user", userId: grantee.id }, [child.id], ws.id);
369 
370 expect(levels).toEqual(new Map([[child.id, "none"]]));
371 expect(await canAccessPage(getDb(), { type: "user", userId: grantee.id }, child.id, ws.id, "view")).toBe(false);
372 });
373 
374 it("preserves a direct share on the descendant when an ancestor is archived", async () => {
375 const owner = await seedUser();
376 const grantee = await seedUser();
377 const ws = await seedWorkspace({ owner_id: owner.id });
378 const parent = await seedPage({
379 workspace_id: ws.id,
380 created_by: owner.id,
381 title: "Archived Parent",
382 archived_at: TEST_TIMESTAMP,
383 });
384 const child = await seedPage({
385 workspace_id: ws.id,
386 created_by: owner.id,
387 parent_id: parent.id,
388 title: "Live Child",
389 });
390 await seedPageShare({
391 page_id: child.id,
392 created_by: owner.id,
393 grantee_type: "user",
394 grantee_id: grantee.id,
395 permission: "view",
396 });
397 
398 const levels = await resolvePageAccessLevels(getDb(), { type: "user", userId: grantee.id }, [child.id], ws.id);
399 
400 expect(levels).toEqual(new Map([[child.id, "view"]]));
401 });
402 
403 it("returns none when the target page itself is archived even with a direct share", async () => {
404 const owner = await seedUser();
405 const grantee = await seedUser();
406 const ws = await seedWorkspace({ owner_id: owner.id });
407 const page = await seedPage({
408 workspace_id: ws.id,
409 created_by: owner.id,
410 title: "Archived Page",
411 archived_at: TEST_TIMESTAMP,
412 });
413 await seedPageShare({
414 page_id: page.id,
415 created_by: owner.id,
416 grantee_type: "user",
417 grantee_id: grantee.id,
418 permission: "edit",
419 });
420 
421 const levels = await resolvePageAccessLevels(getDb(), { type: "user", userId: grantee.id }, [page.id], ws.id);
422 
423 expect(levels).toEqual(new Map([[page.id, "none"]]));
424 });
425 });
426});