Skip to content
File

Blob: tests/worker/routes/page-archive-visibility.workers.test.ts

typescript401 lines
1import { env } from "cloudflare:workers";
2import { beforeEach, describe, expect, it, vi } from "vitest";
3import { eq, sql } from "drizzle-orm";
4 
5import { buildSitePagePath } from "@/worker/lib/site-public-url";
6import { pages } from "@/worker/db/d1/schema";
7import { getDb, resetD1Tables } from "@tests/worker/helpers/db";
8import { apiRequest, expectJson } from "@tests/worker/helpers/request";
9import { refreshCookieFor } from "@tests/worker/helpers/auth";
10import { projectSitePage } from "@tests/worker/helpers/sites";
11import {
12 seedMembership,
13 seedPage,
14 seedPublishedPage,
15 seedUpload,
16 seedUser,
17 seedWorkspace,
18 seedWorkspaceSite,
19} from "@tests/worker/helpers/seeds";
20import { ApiErrorResponse } from "@tests/worker/helpers/schemas";
21 
22const SITE_ORIGIN = "https://acme.sites.test";
23 
24function sentIndexPageIds(): string[] {
25 return vi
26 .mocked(env.TASKS_QUEUE.sendBatch)
27 .mock.calls.flatMap((call) =>
28 Array.from(call[0], (entry) => (entry.body as { type: "index-page"; pageId: string }).pageId),
29 );
30}
31 
32async function resetR2() {
33 let cursor: string | undefined;
34 do {
35 const list = await env.R2.list({ cursor });
36 if (list.objects.length > 0) {
37 await env.R2.delete(list.objects.map((object) => object.key));
38 }
39 cursor = list.truncated ? list.cursor : undefined;
40 } while (cursor);
41}
42 
43describe("page archive visibility contract", () => {
44 beforeEach(async () => {
45 await resetD1Tables();
46 await resetR2();
47 });
48 
49 it("archives an active subtree, preserves parent links, and returns every archived id", async () => {
50 const owner = await seedUser();
51 const ws = await seedWorkspace({ owner_id: owner.id });
52 const root = await seedPage({ id: "root", workspace_id: ws.id, created_by: owner.id, parent_id: null });
53 const child = await seedPage({ id: "child", workspace_id: ws.id, created_by: owner.id, parent_id: root.id });
54 const grandchild = await seedPage({
55 id: "grandchild",
56 workspace_id: ws.id,
57 created_by: owner.id,
58 parent_id: child.id,
59 });
60 
61 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}`, {
62 method: "DELETE",
63 userId: owner.id,
64 });
65 
66 expect(res.status).toBe(200);
67 const body = await expectJson<{ ok: true; archived_page_ids: string[] }>(res);
68 expect(new Set(body.archived_page_ids)).toEqual(new Set([root.id, child.id, grandchild.id]));
69 expect(new Set(sentIndexPageIds())).toEqual(new Set([root.id, child.id, grandchild.id]));
70 
71 const rows = await getDb().select().from(pages).where(eq(pages.workspace_id, ws.id));
72 expect(rows).toHaveLength(3);
73 for (const row of rows) {
74 expect(row.archived_at).toBeTruthy();
75 expect(row.archive_root_id).toBe(root.id);
76 }
77 expect(rows.find((row) => row.id === child.id)?.parent_id).toBe(root.id);
78 expect(rows.find((row) => row.id === grandchild.id)?.parent_id).toBe(child.id);
79 
80 const archivedList = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/archived`, { userId: owner.id });
81 expect(archivedList.status).toBe(200);
82 expect(
83 (await expectJson<{ pages: Array<{ id: string; archived_descendant_count: number }> }>(archivedList)).pages,
84 ).toEqual([expect.objectContaining({ id: root.id, archived_descendant_count: 2 })]);
85 });
86 
87 it("archives and restores subtrees larger than the D1 bound-parameter limit", async () => {
88 const owner = await seedUser();
89 const ws = await seedWorkspace({ owner_id: owner.id });
90 const root = await seedPage({ workspace_id: ws.id, created_by: owner.id, parent_id: null });
91 for (let i = 0; i < 105; i += 1) {
92 await seedPage({ workspace_id: ws.id, created_by: owner.id, parent_id: root.id, position: i });
93 }
94 
95 const archiveRes = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}`, {
96 method: "DELETE",
97 userId: owner.id,
98 });
99 expect(archiveRes.status).toBe(200);
100 const archiveBody = await expectJson<{ archived_page_ids: string[] }>(archiveRes);
101 expect(archiveBody.archived_page_ids).toHaveLength(106);
102 expect(sentIndexPageIds()).toHaveLength(106);
103 
104 vi.mocked(env.TASKS_QUEUE.sendBatch).mockClear();
105 
106 const restoreRes = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}/restore`, {
107 method: "POST",
108 userId: owner.id,
109 });
110 expect(restoreRes.status).toBe(200);
111 const restoreBody = await expectJson<{ ok: true; pages: Array<{ id: string; archived_at: string | null }> }>(
112 restoreRes,
113 );
114 expect(restoreBody.pages).toHaveLength(106);
115 expect(restoreBody.pages.every((page) => page.archived_at === null)).toBe(true);
116 expect(sentIndexPageIds()).toHaveLength(106);
117 
118 const rowsAfterRestore = await getDb().select().from(pages).where(eq(pages.workspace_id, ws.id));
119 const archivedRows = rowsAfterRestore.filter((row) => row.archived_at !== null || row.archive_root_id !== null);
120 expect(archivedRows).toEqual([]);
121 });
122 
123 it("rejects a member archiving a subtree with another user's active page", async () => {
124 const owner = await seedUser({ id: "owner" });
125 const member = await seedUser({ id: "member" });
126 const ws = await seedWorkspace({ owner_id: owner.id });
127 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
128 const root = await seedPage({ workspace_id: ws.id, created_by: member.id });
129 const child = await seedPage({ workspace_id: ws.id, created_by: owner.id, parent_id: root.id });
130 
131 const denied = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}`, {
132 method: "DELETE",
133 userId: member.id,
134 });
135 expect(denied.status).toBe(403);
136 
137 const rowsAfterDenied = await getDb().select().from(pages).where(eq(pages.workspace_id, ws.id));
138 expect(rowsAfterDenied.every((row) => row.archived_at === null && row.archive_root_id === null)).toBe(true);
139 
140 const allowed = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}`, {
141 method: "DELETE",
142 userId: owner.id,
143 });
144 expect(allowed.status).toBe(200);
145 const body = await expectJson<{ archived_page_ids: string[] }>(allowed);
146 expect(new Set(body.archived_page_ids)).toEqual(new Set([root.id, child.id]));
147 });
148 
149 it("skips already archived descendants and restore clears only the matching archive root", async () => {
150 const owner = await seedUser();
151 const ws = await seedWorkspace({ owner_id: owner.id });
152 const root = await seedPage({ id: "root", workspace_id: ws.id, created_by: owner.id });
153 const independentlyArchived = await seedPage({
154 id: "archived-child",
155 workspace_id: ws.id,
156 created_by: owner.id,
157 parent_id: root.id,
158 archived_at: "2026-04-01T00:00:00.000Z",
159 archive_root_id: "archived-child",
160 });
161 
162 const archiveRes = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}`, {
163 method: "DELETE",
164 userId: owner.id,
165 });
166 expect(archiveRes.status).toBe(200);
167 expect((await expectJson<{ archived_page_ids: string[] }>(archiveRes)).archived_page_ids).toEqual([root.id]);
168 
169 let archivedChild = await getDb().select().from(pages).where(eq(pages.id, independentlyArchived.id)).get();
170 expect(archivedChild?.archived_at).toBe("2026-04-01T00:00:00.000Z");
171 expect(archivedChild?.archive_root_id).toBe(independentlyArchived.id);
172 
173 const restoreRes = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}/restore`, {
174 method: "POST",
175 userId: owner.id,
176 });
177 expect(restoreRes.status).toBe(200);
178 expect((await expectJson<{ pages: Array<{ id: string }> }>(restoreRes)).pages.map((page) => page.id)).toEqual([
179 root.id,
180 ]);
181 
182 archivedChild = await getDb().select().from(pages).where(eq(pages.id, independentlyArchived.id)).get();
183 expect(archivedChild?.archived_at).toBe("2026-04-01T00:00:00.000Z");
184 expect(archivedChild?.archive_root_id).toBe(independentlyArchived.id);
185 
186 const childRestoreRes = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${independentlyArchived.id}/restore`, {
187 method: "POST",
188 userId: owner.id,
189 });
190 expect(childRestoreRes.status).toBe(200);
191 archivedChild = await getDb().select().from(pages).where(eq(pages.id, independentlyArchived.id)).get();
192 expect(archivedChild?.archived_at).toBeNull();
193 expect(archivedChild?.archive_root_id).toBeNull();
194 });
195 
196 it("scopes trash roots by workspace role and root creator", async () => {
197 const owner = await seedUser({ id: "owner" });
198 const member = await seedUser({ id: "member" });
199 const guest = await seedUser({ id: "guest" });
200 const ws = await seedWorkspace({ owner_id: owner.id });
201 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
202 await seedMembership({ user_id: guest.id, workspace_id: ws.id, role: "guest" });
203 const ownerRoot = await seedPage({
204 id: "owner-root",
205 workspace_id: ws.id,
206 created_by: owner.id,
207 archived_at: "2026-04-01T00:00:00.000Z",
208 archive_root_id: "owner-root",
209 });
210 const memberRoot = await seedPage({
211 id: "member-root",
212 workspace_id: ws.id,
213 created_by: member.id,
214 archived_at: "2026-04-02T00:00:00.000Z",
215 archive_root_id: "member-root",
216 });
217 
218 const ownerList = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/archived`, { userId: owner.id });
219 expect(ownerList.status).toBe(200);
220 expect((await expectJson<{ pages: Array<{ id: string }> }>(ownerList)).pages.map((page) => page.id)).toEqual([
221 memberRoot.id,
222 ownerRoot.id,
223 ]);
224 
225 const memberList = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/archived`, { userId: member.id });
226 expect(memberList.status).toBe(200);
227 expect((await expectJson<{ pages: Array<{ id: string }> }>(memberList)).pages.map((page) => page.id)).toEqual([
228 memberRoot.id,
229 ]);
230 
231 const guestList = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/archived`, { userId: guest.id });
232 expect(guestList.status).toBe(403);
233 });
234 
235 it("rejects a member restoring a mixed-ownership archived operation", async () => {
236 const owner = await seedUser({ id: "owner" });
237 const member = await seedUser({ id: "member" });
238 const ws = await seedWorkspace({ owner_id: owner.id });
239 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
240 const root = await seedPage({
241 id: "root",
242 workspace_id: ws.id,
243 created_by: member.id,
244 archived_at: "2026-04-01T00:00:00.000Z",
245 archive_root_id: "root",
246 });
247 const child = await seedPage({
248 id: "child",
249 workspace_id: ws.id,
250 created_by: owner.id,
251 parent_id: root.id,
252 archived_at: "2026-04-01T00:00:00.000Z",
253 archive_root_id: root.id,
254 });
255 
256 const denied = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}/restore`, {
257 method: "POST",
258 userId: member.id,
259 });
260 expect(denied.status).toBe(403);
261 expect(ApiErrorResponse.parse(await denied.json()).error).toBe("forbidden");
262 
263 const rowsAfterDenied = await getDb().select().from(pages).where(eq(pages.workspace_id, ws.id));
264 expect(rowsAfterDenied.every((row) => row.archived_at !== null && row.archive_root_id === root.id)).toBe(true);
265 
266 const allowed = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}/restore`, {
267 method: "POST",
268 userId: owner.id,
269 });
270 expect(allowed.status).toBe(200);
271 expect(new Set((await expectJson<{ pages: Array<{ id: string }> }>(allowed)).pages.map((page) => page.id))).toEqual(
272 new Set([root.id, child.id]),
273 );
274 });
275 
276 it("rejects restore for non-root archived descendants and for roots under archived ancestors", async () => {
277 const owner = await seedUser();
278 const ws = await seedWorkspace({ owner_id: owner.id });
279 const root = await seedPage({
280 id: "root",
281 workspace_id: ws.id,
282 created_by: owner.id,
283 archived_at: "2026-04-01T00:00:00.000Z",
284 archive_root_id: "root",
285 });
286 const child = await seedPage({
287 id: "child",
288 workspace_id: ws.id,
289 created_by: owner.id,
290 parent_id: root.id,
291 archived_at: "2026-04-01T00:00:00.000Z",
292 archive_root_id: root.id,
293 });
294 
295 const nonRoot = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${child.id}/restore`, {
296 method: "POST",
297 userId: owner.id,
298 });
299 expect(nonRoot.status).toBe(409);
300 expect(ApiErrorResponse.parse(await nonRoot.json()).error).toBe("not_archive_root");
301 
302 await getDb().update(pages).set({ archive_root_id: child.id }).where(eq(pages.id, child.id));
303 const underArchivedAncestor = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${child.id}/restore`, {
304 method: "POST",
305 userId: owner.id,
306 });
307 expect(underArchivedAncestor.status).toBe(409);
308 expect(ApiErrorResponse.parse(await underArchivedAncestor.json()).error).toBe("archived_ancestor");
309 });
310 
311 it("ignores legacy archived rows until the operator backfill runbook is applied", async () => {
312 const owner = await seedUser();
313 const ws = await seedWorkspace({ owner_id: owner.id });
314 const legacy = await seedPage({
315 workspace_id: ws.id,
316 created_by: owner.id,
317 archived_at: "2026-04-01T00:00:00.000Z",
318 archive_root_id: null,
319 });
320 
321 const listBefore = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/archived`, { userId: owner.id });
322 expect(listBefore.status).toBe(200);
323 expect((await expectJson<{ pages: unknown[] }>(listBefore)).pages).toEqual([]);
324 
325 const restoreBefore = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${legacy.id}/restore`, {
326 method: "POST",
327 userId: owner.id,
328 });
329 expect(restoreBefore.status).toBe(409);
330 expect(ApiErrorResponse.parse(await restoreBefore.json()).error).toBe("not_archive_root");
331 
332 await getDb().run(sql`
333 UPDATE pages
334 SET archive_root_id = id
335 WHERE archived_at IS NOT NULL
336 AND archive_root_id IS NULL
337 `);
338 
339 const listAfter = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/archived`, { userId: owner.id });
340 expect((await expectJson<{ pages: Array<{ id: string }> }>(listAfter)).pages.map((page) => page.id)).toEqual([
341 legacy.id,
342 ]);
343 
344 const restoreAfter = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${legacy.id}/restore`, {
345 method: "POST",
346 userId: owner.id,
347 });
348 expect(restoreAfter.status).toBe(200);
349 const restored = await getDb().select().from(pages).where(eq(pages.id, legacy.id)).get();
350 expect(restored?.archived_at).toBeNull();
351 expect(restored?.archive_root_id).toBeNull();
352 });
353 
354 it("hides archived published descendants from Sites and serves them again after restore", async () => {
355 const owner = await seedUser();
356 const ws = await seedWorkspace({ owner_id: owner.id });
357 const root = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Root" });
358 const child = await seedPage({ workspace_id: ws.id, created_by: owner.id, parent_id: root.id, title: "Child" });
359 await seedWorkspaceSite({ workspace_id: ws.id, slug: "acme" });
360 await seedPublishedPage({ workspace_id: ws.id, page_id: root.id, published_by: owner.id });
361 await projectSitePage(env, child.id);
362 
363 await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}`, { method: "DELETE", userId: owner.id });
364 
365 const hidden = await apiRequest(buildSitePagePath(child.id, "Child"), { origin: SITE_ORIGIN });
366 expect(hidden.status).toBe(404);
367 
368 await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${root.id}/restore`, { method: "POST", userId: owner.id });
369 
370 const visible = await apiRequest(buildSitePagePath(child.id, "Child"), { origin: SITE_ORIGIN });
371 expect(visible.status).toBe(200);
372 expect(await visible.text()).toContain("<title>Child</title>");
373 });
374 
375 it("conceals page-scoped uploads while archived and serves them again after restore", async () => {
376 const owner = await seedUser();
377 const ws = await seedWorkspace({ owner_id: owner.id });
378 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
379 const upload = await seedUpload({
380 workspace_id: ws.id,
381 uploaded_by: owner.id,
382 page_id: page.id,
383 r2_key: "uploads/archive-restore.png",
384 content_type: "image/png",
385 });
386 await env.R2.put(upload.r2_key, new Uint8Array([1, 2, 3]));
387 const cookie = await refreshCookieFor(owner.id);
388 
389 await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}`, { method: "DELETE", userId: owner.id });
390 
391 const hidden = await apiRequest(`/uploads/${upload.id}`, { cookie });
392 expect(hidden.status).toBe(404);
393 
394 await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/restore`, { method: "POST", userId: owner.id });
395 
396 const visible = await apiRequest(`/uploads/${upload.id}`, { cookie });
397 expect(visible.status).toBe(200);
398 expect(visible.headers.get("content-type")).toContain("image/png");
399 });
400});