1
10import { afterAll, beforeAll, describe, expect, it } from "vitest";
11import { createTestApp, type TestAppHandle } from "./helpers/api";
12import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed";
13
14let app: TestAppHandle;
15
16beforeAll(async () => {
17 app = await createTestApp();
18}, 180_000);
19afterAll(async () => {
20 await app?.close();
21});
22
23const json = async (res: Response): Promise<Record<string, unknown>> =>
24 (await res.json().catch(() => ({}))) as Record<string, unknown>;
25
26async function setup(ownerName: string, wsName: string) {
27 const user = await createUser(app.request, { name: ownerName });
28 const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), wsName);
29 return { user, workspaceId, headers: authHeaders(user.accessToken!) };
30}
31
32async function mkPage(workspaceId: string, headers: Record<string, string>, title: string, parentId?: string) {
33 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
34 headers,
35 body: { title, ...(parentId ? { parentId } : {}) }
36 });
37 if (res.status !== 201) throw new Error(`mkPage failed (${res.status}): ${await res.text()}`);
38 return (await json(res)) as { id: string };
39}
40
41async function mkBlock(pageId: string, headers: Record<string, string>, type: string, content: object) {
42
43
44 const res = await app.request("POST", `/api/v1/blocks/batch`, {
45 headers,
46 body: { pageId, ops: [{ op: "upsert", block: { type, content } }] }
47 });
48 if (res.status !== 201 && res.status !== 200) throw new Error(`mkBlock failed (${res.status}): ${await res.text()}`);
49}
50
51describe("Pages: duplicate (T-138)", () => {
52 it("recursive duplicate copies blocks + child pages; new ids; comments not copied", async () => {
53 const { user, workspaceId, headers } = await setup("Dup Owner", "Dup WS");
54 const root = await mkPage(workspaceId, headers, "Root Page");
55 const child = await mkPage(workspaceId, headers, "Child Page", root.id);
56 await mkBlock(root.id, headers, "PARAGRAPH", { text: "root body" });
57 await mkBlock(child.id, headers, "HEADING_2", { text: "child heading" });
58
59 const before = await countAuditEvents(app.prisma, { action: "page.duplicated" });
60 const res = await app.request("POST", `/api/v1/pages/${root.id}/duplicate`, { headers });
61 expect(res.status).toBe(201);
62 const body = await json(res) as { id: string; sourcePageId: string };
63 expect(body.sourcePageId).toBe(root.id);
64 expect(body.id).not.toBe(root.id);
65
66 const copy = await app.prisma.page.findUnique({ where: { id: body.id } });
67 expect(copy?.title).toBe("Root Page (copy)");
68
69
70 const srcBlocks = await app.prisma.block.count({ where: { pageId: root.id, deletedAt: null } });
71 const copyBlocks = await app.prisma.block.count({ where: { pageId: body.id, deletedAt: null } });
72 expect(copyBlocks).toBe(srcBlocks);
73 expect(
74 (await app.prisma.block.findFirst({ where: { pageId: body.id, content: { path: ["text"], equals: "root body" } } }))?.id
75 ).toBeTruthy();
76
77
78 const srcChildBlocks = await app.prisma.block.count({ where: { pageId: child.id, deletedAt: null } });
79 const copyChild = await app.prisma.page.findFirst({ where: { parentId: body.id, deletedAt: null } });
80 expect(copyChild?.title).toBe("Child Page");
81 expect(copyChild?.id).not.toBe(child.id);
82 const copyChildBlocks = await app.prisma.block.count({ where: { pageId: copyChild!.id, deletedAt: null } });
83 expect(copyChildBlocks).toBe(srcChildBlocks);
84
85
86 await app.prisma.comment.create({
87 data: { workspaceId, pageId: root.id, body: "a comment", mentionIds: [], createdBy: user.userId }
88 });
89 const copyComments = await app.prisma.comment.count({ where: { pageId: body.id } });
90 expect(copyComments).toBe(0);
91
92 const after = await countAuditEvents(app.prisma, { action: "page.duplicated" });
93 expect(after).toBe(before + 1);
94 });
95
96 it("duplicate requires EDIT (view-only member → 403)", async () => {
97 const owner = await createUser(app.request, { name: "Dup Guard Owner" });
98 const viewer = await createUser(app.request, { name: "Dup Guard Viewer" });
99 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Dup Guard WS");
100 const page = await mkPage(workspaceId, authHeaders(owner.accessToken!), "Guarded");
101 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, viewer.email, "GUEST");
102
103 const res = await app.request("POST", `/api/v1/pages/${page.id}/duplicate`, {
104 headers: authHeaders(viewer.accessToken!)
105 });
106 expect(res.status).toBe(403);
107 });
108});
109
110describe("Pages: bulk actions (T-139)", () => {
111 it("partial failure: mixed permissions → per-item codes + audit", async () => {
112 const owner = await createUser(app.request, { name: "Bulk Owner" });
113 const member = await createUser(app.request, { name: "Bulk Member" });
114 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Bulk WS");
115 const editable = await mkPage(workspaceId, authHeaders(owner.accessToken!), "Editable");
116 const guarded = await mkPage(workspaceId, authHeaders(owner.accessToken!), "Guarded");
117
118
119
120 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER");
121 await app.prisma.permission.create({
122 data: {
123 workspaceId,
124 granteeType: "user",
125 granteeId: member.userId,
126 resourceType: "page",
127 resourceId: guarded.id,
128 level: "NONE"
129 } as never
130 });
131
132 const res = await app.request("POST", `/api/v1/pages/bulk`, {
133 headers: { ...authHeaders(member.accessToken!), "x-factory-workspace": workspaceId },
134 body: { action: "trash", ids: [editable.id, guarded.id] }
135 });
136 expect(res.status).toBe(201);
137 const body = (await json(res)) as { succeeded: string[]; failed: { id: string; code: string }[] };
138 expect(body.succeeded).toEqual([editable.id]);
139 expect(body.failed).toEqual([{ id: guarded.id, code: "FORBIDDEN" }]);
140
141
142 expect((await app.prisma.page.findUnique({ where: { id: editable.id } }))?.trashedAt).not.toBeNull();
143 expect((await app.prisma.page.findUnique({ where: { id: guarded.id } }))?.trashedAt).toBeNull();
144
145 const audit = await countAuditEvents(app.prisma, { action: "page.bulk" });
146 expect(audit).toBeGreaterThanOrEqual(1);
147 });
148
149 it("unknown ids → NOT_FOUND codes (no leak of other tenants' pages)", async () => {
150 const { user, workspaceId, headers } = await setup("Bulk Iso", "Bulk Iso WS");
151 const other = await createUser(app.request, { name: "Bulk Iso Other" });
152 const foreign = await mkPage((await createWorkspace(app.request, authHeaders(other.accessToken!), "Bulk Iso Other WS")).workspaceId, authHeaders(other.accessToken!), "Foreign");
153
154 const res = await app.request("POST", `/api/v1/pages/bulk`, {
155 headers: { ...headers, "x-factory-workspace": workspaceId },
156 body: { action: "trash", ids: [foreign.id] }
157 });
158 expect(res.status).toBe(201);
159 const body = (await json(res)) as { failed: { id: string; code: string }[] };
160 expect(body.failed).toEqual([{ id: foreign.id, code: "NOT_FOUND" }]);
161 });
162});
163
164describe("Pages: backlinks (T-140)", () => {
165 it("child_page + linked_database + comment-mention sources, permission-filtered", async () => {
166 const owner = await createUser(app.request, { name: "Back Owner" });
167 const member = await createUser(app.request, { name: "Back Member" });
168 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Back WS");
169 const ownerH = authHeaders(owner.accessToken!);
170
171 const target = await mkPage(workspaceId, ownerH, "Target Page");
172 const ref1 = await mkPage(workspaceId, ownerH, "Ref via child_page");
173 const ref2 = await mkPage(workspaceId, ownerH, "Ref via comment");
174
175 const secretRef = await mkPage(workspaceId, ownerH, "Secret Ref");
176 await app.prisma.permission.create({
177 data: { workspaceId, granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: secretRef.id, level: "NONE" } as never
178 });
179
180
181 await mkBlock(ref1.id, ownerH, "CHILD_PAGE", { pageId: target.id });
182
183 for (const p of [ref2.id, secretRef.id]) {
184 await app.prisma.comment.create({
185 data: { workspaceId, pageId: p, body: "see target", mentionIds: [], pageMentionIds: [target.id], createdBy: owner.userId }
186 });
187 }
188
189 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER");
190
191
192 const res = await app.request("GET", `/api/v1/pages/${target.id}/backlinks`, {
193 headers: authHeaders(member.accessToken!)
194 });
195 expect(res.status).toBe(200);
196 const body = (await json(res)) as { data: { pageId: string; title: string }[] };
197 const titles = body.data.map((d) => d.title).sort();
198 expect(titles).toContain("Ref via child_page");
199 expect(titles).toContain("Ref via comment");
200 expect(titles).not.toContain("Secret Ref");
201
202
203 const ownerRes = await app.request("GET", `/api/v1/pages/${target.id}/backlinks`, { headers: ownerH });
204 const ownerBody = (await json(ownerRes)) as { data: { title: string }[] };
205 expect(ownerBody.data.map((d) => d.title).sort()).toEqual([
206 "Ref via child_page",
207 "Ref via comment",
208 "Secret Ref"
209 ].sort());
210
211
212 expect(body.data.map((d) => d.title)).not.toContain("Target Page");
213 });
214
215 it("cross-workspace move requires EDIT in BOTH workspaces (T-149)", async () => {
216 const owner = await createUser(app.request, { name: "Move Owner" });
217 const mover = await createUser(app.request, { name: "Move Mover" });
218 const h = authHeaders(owner.accessToken!);
219 const wsA = (await createWorkspace(app.request, h, "Move A")).workspaceId;
220 const wsB = (await createWorkspace(app.request, h, "Move B")).workspaceId;
221 const page = await mkPage(wsA, h, "Movable");
222
223
224 const outsider = await createUser(app.request, { name: "Move Outsider" });
225 await addMemberViaSeam(app.request, app.internalServiceToken, wsA, outsider.email, "MEMBER");
226 const r1 = await app.request("POST", `/api/v1/pages/${page.id}/move`, {
227 headers: authHeaders(outsider.accessToken!),
228 body: { toWorkspaceId: wsB }
229 });
230 expect(r1.status).toBe(403);
231
232
233 await addMemberViaSeam(app.request, app.internalServiceToken, wsB, mover.email, "MEMBER");
234 const r2 = await app.request("POST", `/api/v1/pages/${page.id}/move`, {
235 headers: authHeaders(mover.accessToken!),
236 body: { toWorkspaceId: wsB }
237 });
238 expect(r2.status).toBe(403);
239
240
241 await addMemberViaSeam(app.request, app.internalServiceToken, wsA, mover.email, "MEMBER");
242 const r3 = await app.request("POST", `/api/v1/pages/${page.id}/move`, {
243 headers: authHeaders(mover.accessToken!),
244 body: { toWorkspaceId: wsB }
245 });
246 expect(r3.status).toBe(201);
247 const moved = await app.prisma.page.findUnique({ where: { id: page.id } });
248 expect(moved?.workspaceId).toBe(wsB);
249 const block = await app.prisma.block.findFirst({ where: { pageId: page.id } });
250 expect(block?.workspaceId).toBe(wsB);
251 });
252
253 it("requires VIEW on the target (non-member → 403)", async () => {
254 const { workspaceId, headers } = await setup("Back Guard", "Back Guard WS");
255 const outsider = await createUser(app.request, { name: "Back Outsider" });
256 const page = await mkPage(workspaceId, headers, "Guarded Target");
257 const res = await app.request("GET", `/api/v1/pages/${page.id}/backlinks`, {
258 headers: authHeaders(outsider.accessToken!)
259 });
260 expect(res.status).toBe(403);
261 });
262});
263