File
Blob: tests/worker/routes/page-archive-visibility.workers.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | import { eq, sql } from "drizzle-orm"; |
| 4 | |
| 5 | import { buildSitePagePath } from "@/worker/lib/site-public-url"; |
| 6 | import { pages } from "@/worker/db/d1/schema"; |
| 7 | import { getDb, resetD1Tables } from "@tests/worker/helpers/db"; |
| 8 | import { apiRequest, expectJson } from "@tests/worker/helpers/request"; |
| 9 | import { refreshCookieFor } from "@tests/worker/helpers/auth"; |
| 10 | import { projectSitePage } from "@tests/worker/helpers/sites"; |
| 11 | import { |
| 12 | seedMembership, |
| 13 | seedPage, |
| 14 | seedPublishedPage, |
| 15 | seedUpload, |
| 16 | seedUser, |
| 17 | seedWorkspace, |
| 18 | seedWorkspaceSite, |
| 19 | } from "@tests/worker/helpers/seeds"; |
| 20 | import { ApiErrorResponse } from "@tests/worker/helpers/schemas"; |
| 21 | |
| 22 | const SITE_ORIGIN = "https://acme.sites.test"; |
| 23 | |
| 24 | function 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 | |
| 32 | async 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 | |
| 43 | describe("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 | }); |