/** * AI Software Factory — Integration: Pages T-138/T-139/T-140 (Confluence expansion) * * - Duplicate (recursive): new ids, block tree copied, database snapshot, * comments NOT copied; view-only user gets 403. * - Bulk actions: per-item permission + partial-failure codes + audit. * - Backlinks: child_page + linked_database + comment mention sources, * permission-filtered (fail-closed: an unauthorized source is excluded). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; async function setup(ownerName: string, wsName: string) { const user = await createUser(app.request, { name: ownerName }); const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), wsName); return { user, workspaceId, headers: authHeaders(user.accessToken!) }; } async function mkPage(workspaceId: string, headers: Record, title: string, parentId?: string) { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title, ...(parentId ? { parentId } : {}) } }); if (res.status !== 201) throw new Error(`mkPage failed (${res.status}): ${await res.text()}`); return (await json(res)) as { id: string }; } async function mkBlock(pageId: string, headers: Record, type: string, content: object) { // `POST /api/v1/blocks/batch` (root-mounted blocks controller; pageId in // the body; ops are the discriminated union — insert = op:"upsert"). const res = await app.request("POST", `/api/v1/blocks/batch`, { headers, body: { pageId, ops: [{ op: "upsert", block: { type, content } }] } }); if (res.status !== 201 && res.status !== 200) throw new Error(`mkBlock failed (${res.status}): ${await res.text()}`); } describe("Pages: duplicate (T-138)", () => { it("recursive duplicate copies blocks + child pages; new ids; comments not copied", async () => { const { user, workspaceId, headers } = await setup("Dup Owner", "Dup WS"); const root = await mkPage(workspaceId, headers, "Root Page"); const child = await mkPage(workspaceId, headers, "Child Page", root.id); await mkBlock(root.id, headers, "PARAGRAPH", { text: "root body" }); await mkBlock(child.id, headers, "HEADING_2", { text: "child heading" }); const before = await countAuditEvents(app.prisma, { action: "page.duplicated" }); const res = await app.request("POST", `/api/v1/pages/${root.id}/duplicate`, { headers }); expect(res.status).toBe(201); const body = await json(res) as { id: string; sourcePageId: string }; expect(body.sourcePageId).toBe(root.id); expect(body.id).not.toBe(root.id); const copy = await app.prisma.page.findUnique({ where: { id: body.id } }); expect(copy?.title).toBe("Root Page (copy)"); // The copy's block tree mirrors the source's (page create seeds a title // block, so the root has 2: title + the paragraph we added). const srcBlocks = await app.prisma.block.count({ where: { pageId: root.id, deletedAt: null } }); const copyBlocks = await app.prisma.block.count({ where: { pageId: body.id, deletedAt: null } }); expect(copyBlocks).toBe(srcBlocks); expect( (await app.prisma.block.findFirst({ where: { pageId: body.id, content: { path: ["text"], equals: "root body" } } }))?.id ).toBeTruthy(); // child page copied with its block tree const srcChildBlocks = await app.prisma.block.count({ where: { pageId: child.id, deletedAt: null } }); const copyChild = await app.prisma.page.findFirst({ where: { parentId: body.id, deletedAt: null } }); expect(copyChild?.title).toBe("Child Page"); expect(copyChild?.id).not.toBe(child.id); const copyChildBlocks = await app.prisma.block.count({ where: { pageId: copyChild!.id, deletedAt: null } }); expect(copyChildBlocks).toBe(srcChildBlocks); // comments are NOT copied (add one to source, verify absent on copy) await app.prisma.comment.create({ data: { workspaceId, pageId: root.id, body: "a comment", mentionIds: [], createdBy: user.userId } }); const copyComments = await app.prisma.comment.count({ where: { pageId: body.id } }); expect(copyComments).toBe(0); const after = await countAuditEvents(app.prisma, { action: "page.duplicated" }); expect(after).toBe(before + 1); }); it("duplicate requires EDIT (view-only member → 403)", async () => { const owner = await createUser(app.request, { name: "Dup Guard Owner" }); const viewer = await createUser(app.request, { name: "Dup Guard Viewer" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Dup Guard WS"); const page = await mkPage(workspaceId, authHeaders(owner.accessToken!), "Guarded"); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, viewer.email, "GUEST"); const res = await app.request("POST", `/api/v1/pages/${page.id}/duplicate`, { headers: authHeaders(viewer.accessToken!) }); expect(res.status).toBe(403); }); }); describe("Pages: bulk actions (T-139)", () => { it("partial failure: mixed permissions → per-item codes + audit", async () => { const owner = await createUser(app.request, { name: "Bulk Owner" }); const member = await createUser(app.request, { name: "Bulk Member" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Bulk WS"); const editable = await mkPage(workspaceId, authHeaders(owner.accessToken!), "Editable"); const guarded = await mkPage(workspaceId, authHeaders(owner.accessToken!), "Guarded"); // `member` is a MEMBER (EDIT everywhere); the `guarded` page carries an // explicit NONE deny for the member — the engine's hard-stop deny // (overrides the role baseline, per lib/authorize.ts). await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER"); await app.prisma.permission.create({ data: { workspaceId, granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: guarded.id, level: "NONE" } as never }); const res = await app.request("POST", `/api/v1/pages/bulk`, { headers: { ...authHeaders(member.accessToken!), "x-factory-workspace": workspaceId }, body: { action: "trash", ids: [editable.id, guarded.id] } }); expect(res.status).toBe(201); const body = (await json(res)) as { succeeded: string[]; failed: { id: string; code: string }[] }; expect(body.succeeded).toEqual([editable.id]); expect(body.failed).toEqual([{ id: guarded.id, code: "FORBIDDEN" }]); // the editable page is trashed; the guarded one is not expect((await app.prisma.page.findUnique({ where: { id: editable.id } }))?.trashedAt).not.toBeNull(); expect((await app.prisma.page.findUnique({ where: { id: guarded.id } }))?.trashedAt).toBeNull(); const audit = await countAuditEvents(app.prisma, { action: "page.bulk" }); expect(audit).toBeGreaterThanOrEqual(1); }); it("unknown ids → NOT_FOUND codes (no leak of other tenants' pages)", async () => { const { user, workspaceId, headers } = await setup("Bulk Iso", "Bulk Iso WS"); const other = await createUser(app.request, { name: "Bulk Iso Other" }); const foreign = await mkPage((await createWorkspace(app.request, authHeaders(other.accessToken!), "Bulk Iso Other WS")).workspaceId, authHeaders(other.accessToken!), "Foreign"); const res = await app.request("POST", `/api/v1/pages/bulk`, { headers: { ...headers, "x-factory-workspace": workspaceId }, body: { action: "trash", ids: [foreign.id] } }); expect(res.status).toBe(201); const body = (await json(res)) as { failed: { id: string; code: string }[] }; expect(body.failed).toEqual([{ id: foreign.id, code: "NOT_FOUND" }]); }); }); describe("Pages: backlinks (T-140)", () => { it("child_page + linked_database + comment-mention sources, permission-filtered", async () => { const owner = await createUser(app.request, { name: "Back Owner" }); const member = await createUser(app.request, { name: "Back Member" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Back WS"); const ownerH = authHeaders(owner.accessToken!); const target = await mkPage(workspaceId, ownerH, "Target Page"); const ref1 = await mkPage(workspaceId, ownerH, "Ref via child_page"); const ref2 = await mkPage(workspaceId, ownerH, "Ref via comment"); // a page the member may NOT see (explicit NONE deny) references the target too const secretRef = await mkPage(workspaceId, ownerH, "Secret Ref"); await app.prisma.permission.create({ data: { workspaceId, granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: secretRef.id, level: "NONE" } as never }); // ref1 embeds target via child_page block await mkBlock(ref1.id, ownerH, "CHILD_PAGE", { pageId: target.id }); // ref2 + secretRef mention target via comment for (const p of [ref2.id, secretRef.id]) { await app.prisma.comment.create({ data: { workspaceId, pageId: p, body: "see target", mentionIds: [], pageMentionIds: [target.id], createdBy: owner.userId } }); } await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER"); // member: sees ref1 + ref2, NOT secretRef (fail-closed) const res = await app.request("GET", `/api/v1/pages/${target.id}/backlinks`, { headers: authHeaders(member.accessToken!) }); expect(res.status).toBe(200); const body = (await json(res)) as { data: { pageId: string; title: string }[] }; const titles = body.data.map((d) => d.title).sort(); expect(titles).toContain("Ref via child_page"); expect(titles).toContain("Ref via comment"); expect(titles).not.toContain("Secret Ref"); // owner sees all three const ownerRes = await app.request("GET", `/api/v1/pages/${target.id}/backlinks`, { headers: ownerH }); const ownerBody = (await json(ownerRes)) as { data: { title: string }[] }; expect(ownerBody.data.map((d) => d.title).sort()).toEqual([ "Ref via child_page", "Ref via comment", "Secret Ref" ].sort()); // target page itself is never its own backlink expect(body.data.map((d) => d.title)).not.toContain("Target Page"); }); it("cross-workspace move requires EDIT in BOTH workspaces (T-149)", async () => { const owner = await createUser(app.request, { name: "Move Owner" }); const mover = await createUser(app.request, { name: "Move Mover" }); const h = authHeaders(owner.accessToken!); const wsA = (await createWorkspace(app.request, h, "Move A")).workspaceId; const wsB = (await createWorkspace(app.request, h, "Move B")).workspaceId; const page = await mkPage(wsA, h, "Movable"); // 1) non-member of destination → 403 (even with EDIT in source) const outsider = await createUser(app.request, { name: "Move Outsider" }); await addMemberViaSeam(app.request, app.internalServiceToken, wsA, outsider.email, "MEMBER"); const r1 = await app.request("POST", `/api/v1/pages/${page.id}/move`, { headers: authHeaders(outsider.accessToken!), body: { toWorkspaceId: wsB } }); expect(r1.status).toBe(403); // 2) destination member without source access → 403 (source check first) await addMemberViaSeam(app.request, app.internalServiceToken, wsB, mover.email, "MEMBER"); const r2 = await app.request("POST", `/api/v1/pages/${page.id}/move`, { headers: authHeaders(mover.accessToken!), body: { toWorkspaceId: wsB } }); expect(r2.status).toBe(403); // 3) both sides → 201, subtree re-homed, blocks re-stamped await addMemberViaSeam(app.request, app.internalServiceToken, wsA, mover.email, "MEMBER"); const r3 = await app.request("POST", `/api/v1/pages/${page.id}/move`, { headers: authHeaders(mover.accessToken!), body: { toWorkspaceId: wsB } }); expect(r3.status).toBe(201); const moved = await app.prisma.page.findUnique({ where: { id: page.id } }); expect(moved?.workspaceId).toBe(wsB); const block = await app.prisma.block.findFirst({ where: { pageId: page.id } }); expect(block?.workspaceId).toBe(wsB); }); it("requires VIEW on the target (non-member → 403)", async () => { const { workspaceId, headers } = await setup("Back Guard", "Back Guard WS"); const outsider = await createUser(app.request, { name: "Back Outsider" }); const page = await mkPage(workspaceId, headers, "Guarded Target"); const res = await app.request("GET", `/api/v1/pages/${page.id}/backlinks`, { headers: authHeaders(outsider.accessToken!) }); expect(res.status).toBe(403); }); });