Skip to content
File

Blob: tests/worker/routes/page-tree-children.workers.test.ts

typescript163 lines
1import { beforeEach, describe, expect, it } from "vitest";
2 
3import { resetD1Tables } from "@tests/worker/helpers/db";
4import { apiRequest } from "@tests/worker/helpers/request";
5import { seedPage, seedPageShare, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
6 
7interface ChildrenResponse {
8 pages: Array<{ id: string; title: string }>;
9}
10 
11describe("GET /workspaces/:wid/pages/:id/children - access gating", () => {
12 beforeEach(async () => {
13 await resetD1Tables();
14 });
15 
16 it("returns 401 when no principal can be resolved", async () => {
17 const owner = await seedUser();
18 const ws = await seedWorkspace({ owner_id: owner.id });
19 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
20 
21 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/children`, {
22 origin: "https://bland.test",
23 });
24 expect(res.status).toBe(401);
25 });
26 
27 it("returns 404 not_found for a missing parent (member caller)", async () => {
28 const owner = await seedUser();
29 const ws = await seedWorkspace({ owner_id: owner.id });
30 
31 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/page-does-not-exist/children`, {
32 userId: owner.id,
33 });
34 expect(res.status).toBe(404);
35 const body = (await res.json()) as { error: string };
36 expect(body.error).toBe("not_found");
37 });
38 
39 it("returns 404 not_found for an inaccessible existing parent (no existence leak)", async () => {
40 const owner = await seedUser();
41 const outsider = await seedUser();
42 const ws = await seedWorkspace({ owner_id: owner.id });
43 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
44 
45 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/children`, {
46 userId: outsider.id,
47 });
48 expect(res.status).toBe(404);
49 const body = (await res.json()) as { error: string };
50 expect(body.error).toBe("not_found");
51 });
52 
53 it("returns 200 with empty pages when accessible parent has no children", async () => {
54 const owner = await seedUser();
55 const ws = await seedWorkspace({ owner_id: owner.id });
56 const parent = await seedPage({ workspace_id: ws.id, created_by: owner.id });
57 
58 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${parent.id}/children`, {
59 userId: owner.id,
60 });
61 expect(res.status).toBe(200);
62 const body = (await res.json()) as ChildrenResponse;
63 expect(body.pages).toEqual([]);
64 });
65 
66 it("returns inherited children for a shared-link viewer when no nested share replaces inheritance", async () => {
67 const owner = await seedUser();
68 const ws = await seedWorkspace({ owner_id: owner.id });
69 const parent = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Parent" });
70 await seedPageShare({
71 page_id: parent.id,
72 created_by: owner.id,
73 grantee_type: "link",
74 grantee_id: null,
75 link_token: "tokA",
76 permission: "view",
77 });
78 const childA = await seedPage({
79 workspace_id: ws.id,
80 created_by: owner.id,
81 parent_id: parent.id,
82 title: "Inherited Child A",
83 position: 1,
84 });
85 const childB = await seedPage({
86 workspace_id: ws.id,
87 created_by: owner.id,
88 parent_id: parent.id,
89 title: "Replaced Child B",
90 position: 2,
91 });
92 // B has its own share row for a different token: replace-not-merge means B's
93 // nearest share is itself, which has no row matching `tokA` → none for tokA.
94 await seedPageShare({
95 page_id: childB.id,
96 created_by: owner.id,
97 grantee_type: "link",
98 grantee_id: null,
99 link_token: "tokB",
100 permission: "view",
101 });
102 
103 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${parent.id}/children`, {
104 shareToken: "tokA",
105 });
106 expect(res.status).toBe(200);
107 const body = (await res.json()) as ChildrenResponse;
108 const ids = body.pages.map((p) => p.id);
109 expect(ids).toEqual([childA.id]);
110 expect(ids).not.toContain(childB.id);
111 });
112 
113 it("returns the replaced child for a viewer holding the matching nested token", async () => {
114 const owner = await seedUser();
115 const ws = await seedWorkspace({ owner_id: owner.id });
116 const parent = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Parent" });
117 await seedPageShare({
118 page_id: parent.id,
119 created_by: owner.id,
120 grantee_type: "link",
121 grantee_id: null,
122 link_token: "tokA",
123 permission: "view",
124 });
125 const childA = await seedPage({
126 workspace_id: ws.id,
127 created_by: owner.id,
128 parent_id: parent.id,
129 title: "Inherited Child A",
130 position: 1,
131 });
132 const childB = await seedPage({
133 workspace_id: ws.id,
134 created_by: owner.id,
135 parent_id: parent.id,
136 title: "Direct-share Child B",
137 position: 2,
138 });
139 await seedPageShare({
140 page_id: childB.id,
141 created_by: owner.id,
142 grantee_type: "link",
143 grantee_id: null,
144 link_token: "tokB",
145 permission: "view",
146 });
147 // Sanity counter: with tokB the parent is reachable through B's grant only if
148 // B itself is the requested parent; here we request the original parent so a
149 // tokB viewer should see 404 (no access on the parent page itself).
150 const resOnParent = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${parent.id}/children`, {
151 shareToken: "tokB",
152 });
153 expect(resOnParent.status).toBe(404);
154 
155 // Requesting children of B itself with tokB should succeed.
156 const resOnB = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${childB.id}/children`, {
157 shareToken: "tokB",
158 });
159 expect(resOnB.status).toBe(200);
160 void childA;
161 });
162});