1
27
28process.env.AI_PROVIDER = "mock";
29process.env.SEARCH_VECTOR_DIMENSION = "8";
30
31import { afterAll, beforeAll, describe, expect, it } from "vitest";
32import { createTestApp, type TestAppHandle } from "./helpers/api";
33import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
34
35let app: TestAppHandle;
36const RUN = `ast${Date.now().toString(36)}`;
37
38type User = Awaited<ReturnType<typeof createUser>>;
39interface Ws {
40 workspaceId: string;
41 owner: User;
42 member: User;
43 editor: User;
44}
45
46
47
48let ws: Ws;
49let notePageId = "";
50let privatePageId = "";
51let dupPageIds: string[] = [];
52let stalePageIds: string[] = [];
53
54beforeAll(async () => {
55 app = await createTestApp();
56
57 const owner = await createUser(app.request, { name: `Assistant Owner ${RUN}` });
58 const member = await createUser(app.request, { name: `Assistant Member ${RUN}` });
59 const editor = await createUser(app.request, { name: `Assistant Editor ${RUN}` });
60 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), `Assistant WS ${RUN}`);
61 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "GUEST");
62 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, editor.email, "MEMBER");
63 ws = { workspaceId, owner, member, editor };
64
65
66 const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
67 headers: authHeaders(owner.accessToken!),
68 body: { title: "Onboarding Guide" },
69 });
70 expect(pageRes.status).toBe(201);
71 notePageId = ((await pageRes.json()) as { id: string }).id;
72 const blockRes = await app.request("POST", `/api/v1/pages/${notePageId}/blocks`, {
73 headers: authHeaders(owner.accessToken!),
74 body: { type: "PARAGRAPH", content: { text: "Set up your account, invite the team, and configure the workspace settings." } },
75 });
76 expect(blockRes.status).toBe(201);
77
78
79 const privRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
80 headers: authHeaders(owner.accessToken!),
81 body: { title: `Classified ${RUN}` },
82 });
83 privatePageId = ((await privRes.json()) as { id: string }).id;
84 await app.request("POST", `/api/v1/pages/${privatePageId}/blocks`, {
85 headers: authHeaders(owner.accessToken!),
86 body: { type: "PARAGRAPH", content: { text: `Secret fact ${RUN} that must never leak.` } },
87 });
88 await app.prisma.permission.create({
89 data: {
90 workspaceId,
91 granteeType: "user",
92 granteeId: member.userId,
93 resourceType: "page",
94 resourceId: privatePageId,
95 level: "NONE",
96 },
97 });
98
99
100
101 await app.prisma.permission.create({
102 data: {
103 workspaceId,
104 granteeType: "user",
105 granteeId: editor.userId,
106 resourceType: "page",
107 resourceId: privatePageId,
108 level: "NONE",
109 },
110 });
111
112
113 for (const i of [1, 2, 3, 4]) {
114 const r = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
115 headers: authHeaders(owner.accessToken!),
116 body: { title: `Dup Pool ${RUN} p${i}` },
117 });
118 dupPageIds.push(((await r.json()) as { id: string }).id);
119 await app.request("POST", `/api/v1/pages/${dupPageIds[i - 1]}/blocks`, {
120 headers: authHeaders(owner.accessToken!),
121 body: { type: "PARAGRAPH", content: { text: `Dup body ${i} ${RUN}.` } },
122 });
123 }
124
125 await app.prisma.permission.create({
126 data: {
127 workspaceId,
128 granteeType: "user",
129 granteeId: member.userId,
130 resourceType: "page",
131 resourceId: dupPageIds[3]!,
132 level: "NONE",
133 },
134 });
135
136
137 for (const i of [1, 2, 3]) {
138 const r = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
139 headers: authHeaders(owner.accessToken!),
140 body: { title: `Stale Pool ${RUN} s${i}` },
141 });
142 stalePageIds.push(((await r.json()) as { id: string }).id);
143 await app.request("POST", `/api/v1/pages/${stalePageIds[i - 1]}/blocks`, {
144 headers: authHeaders(owner.accessToken!),
145 body: { type: "PARAGRAPH", content: { text: `Stale body ${i} ${RUN}.` } },
146 });
147 }
148 await app.prisma.page.updateMany({
149 where: { id: { in: stalePageIds } },
150 data: { updatedAt: new Date(Date.now() - 200 * 86_400_000) },
151 });
152
153
154
155 await app.prisma.page.update({
156 where: { id: privatePageId },
157 data: { updatedAt: new Date(Date.now() - 250 * 86_400_000) },
158 });
159}, 180_000);
160
161afterAll(async () => {
162 await app?.close();
163});
164
165
166
167
168
169async function call(
170 token: string,
171 path: string,
172 body: Record<string, unknown>,
173 workspaceId?: string,
174): Promise<{ status: number; json: Record<string, any> }> {
175 const res = await app.request("POST", `/api/v1/${path.replace(/^workspaces/, "workspaces")}`, {
176 headers: authHeaders(token),
177 body,
178 });
179 const text = await res.text();
180 let json: Record<string, any> = {};
181 try {
182 json = JSON.parse(text);
183 } catch {
184 json = { raw: text };
185 }
186 return { status: res.status, json };
187}
188
189async function auditCount(action: string, workspaceId: string): Promise<number> {
190 return app.prisma.auditLog.count({ where: { workspaceId, action } });
191}
192
193
194
195
196
197describe("assistant extract-actions (T-172)", () => {
198 it("creates real tasks from the notes' bullet lines (as the requester)", async () => {
199 const before = await auditCount("ai.assistant_extract_actions", ws.workspaceId);
200 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/extract-actions`, {
201 text: "Team sync:\n- Ship the whiteboard export\n- Review the Q3 budget\n- Update the onboarding doc\n\nNo further items.",
202 });
203 expect(out.status).toBe(200);
204 expect(out.json.tasks).toHaveLength(3);
205 expect(out.json.tasks[0].title).toBe("Ship the whiteboard export");
206 expect(out.json.tasks[1].title).toBe("Review the Q3 budget");
207 expect(out.json.tasks[2].title).toBe("Update the onboarding doc");
208
209
210 const rows = await app.prisma.task.findMany({
211 where: { workspaceId: ws.workspaceId, title: { in: out.json.tasks.map((t: any) => t.title) } },
212 });
213 expect(rows).toHaveLength(3);
214 for (const row of rows) {
215 expect(row.status).toBe("todo");
216 const page = await app.prisma.page.findUnique({ where: { id: row.pageId }, select: { kind: true } });
217 expect(page?.kind).toBe("task");
218 }
219
220
221 const list = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=all`, {
222 headers: authHeaders(ws.owner.accessToken!),
223 });
224 expect(list.status).toBe(200);
225 const titles = ((await list.json()) as { data: Array<{ title: string }> }).data.map((t) => t.title);
226 expect(titles).toContain("Ship the whiteboard export");
227
228
229 expect(await auditCount("ai.assistant_extract_actions", ws.workspaceId)).toBe(before + 1);
230 });
231
232 it("GUEST (no EDIT) → 403 (the capability is EDIT-gated)", async () => {
233 const out = await call(ws.member.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/extract-actions`, {
234 text: "- Do something",
235 });
236 expect(out.status).toBe(403);
237 });
238
239 it("empty/missing notes → 400 VALIDATION_ERROR", async () => {
240 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/extract-actions`, { text: " " });
241 expect(out.status).toBe(400);
242 });
243});
244
245
246
247
248
249describe("assistant generate-faqs (T-173)", () => {
250 it("builds an FAQ draft page (H2 question + paragraph answer per FAQ)", async () => {
251 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/generate-faqs`, {
252 pageIds: [notePageId],
253 });
254 expect(out.status).toBe(200);
255 expect(out.json.faqCount).toBe(1);
256 const pageId = out.json.page.id;
257
258 const page = await app.prisma.page.findUnique({ where: { id: pageId } });
259 expect(page?.workspaceId).toBe(ws.workspaceId);
260
261 const blocks = await app.prisma.block.findMany({ where: { pageId, deletedAt: null }, orderBy: { position: "asc" } });
262 expect(blocks.map((b) => b.type)).toEqual(["PARAGRAPH", "HEADING_2", "PARAGRAPH"]);
263 expect(JSON.stringify(blocks[2]!.content)).toMatch(/per \[1\]/);
264 });
265
266 it("never reads a page the caller lacks VIEW on (the NONE page is dropped pre-prompt)", async () => {
267
268
269
270 const out = await call(ws.editor.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/generate-faqs`, {
271 pageIds: [notePageId, privatePageId],
272 });
273 expect(out.status).toBe(200);
274 const pageId = out.json.page.id;
275 const blocks = await app.prisma.block.findMany({ where: { pageId, deletedAt: null } });
276 const text = JSON.stringify(blocks.map((b) => b.content));
277 expect(text).not.toContain(`Secret fact ${RUN}`);
278 });
279
280 it("non-member → 403 (the AI surface asserts the workspace first, like ai-writing)", async () => {
281 const outsider = await createUser(app.request, { name: `Assistant Outsider ${RUN}` });
282 const out = await call(outsider.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/generate-faqs`, {
283 pageIds: [notePageId],
284 });
285 expect(out.status).toBe(403);
286 });
287});
288
289
290
291
292
293describe("assistant find-duplicates (T-174)", () => {
294 it("groups the most-recent pages (candidate pool, permission-filtered)", async () => {
295 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-duplicates`, {});
296 expect(out.status).toBe(200);
297 expect(out.json.considered).toBeGreaterThanOrEqual(4);
298
299 expect(out.json.groups.length).toBeGreaterThanOrEqual(1);
300 const idsInGroups = out.json.groups.flatMap((g: any) => g.pages.map((p: any) => p.pageId));
301 expect(idsInGroups).toContain(dupPageIds[0]);
302 });
303
304 it("never returns the NONE-granted page for the member (security negative)", async () => {
305 const out = await call(ws.member.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-duplicates`, {});
306 expect(out.status).toBe(200);
307 const idsInGroups = out.json.groups.flatMap((g: any) => g.pages.map((p: any) => p.pageId));
308 expect(idsInGroups).not.toContain(dupPageIds[3]);
309 expect(idsInGroups).not.toContain(privatePageId);
310 });
311
312 it("drops model-emitted ids that are not in the retrieved set (no fabricated refs)", async () => {
313 const arm = await app.request("POST", `/internal/workspaces/${ws.workspaceId}/ai/mock-assistant`, {
314 headers: { Authorization: `Bearer ${app.internalServiceToken}` },
315 body: { forgeId: true },
316 });
317 expect(arm.status).toBe(200);
318 try {
319 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-duplicates`, {});
320 expect(out.status).toBe(200);
321 const idsInGroups = out.json.groups.flatMap((g: any) => g.pages.map((p: any) => p.pageId));
322 expect(idsInGroups).not.toContain("forged-id");
323
324 const audit = await app.prisma.auditLog.findFirst({
325 where: { workspaceId: ws.workspaceId, action: "ai.assistant_duplicates_found" },
326 orderBy: { createdAt: "desc" },
327 });
328 expect(audit).toBeTruthy();
329 } finally {
330 await import("@factory/api/src/modules/ai/gateway-provider").then((m) => m.resetWorkspaceCache());
331 }
332 });
333});
334
335
336
337
338
339describe("assistant find-stale (T-174)", () => {
340 it("flags stale pages from the backdated pool (server updatedAt filter)", async () => {
341 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-stale`, { days: 90 });
342 expect(out.status).toBe(200);
343
344
345 expect(out.json.considered).toBeGreaterThanOrEqual(2);
346 expect(out.json.pages.length).toBeGreaterThanOrEqual(1);
347 const flagged = new Set(out.json.pages.map((p: any) => p.pageId));
348 for (const p of out.json.pages) {
349 expect(stalePageIds).toContain(p.pageId);
350 expect(p.title.length).toBeGreaterThan(0);
351 expect(p.reason.length).toBeGreaterThan(0);
352 }
353 expect(flagged.size).toBe(out.json.pages.length);
354 });
355
356 it("never flags the NONE-granted page for the member (security negative)", async () => {
357
358
359 const out = await call(ws.member.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-stale`, { days: 365 });
360 expect(out.status).toBe(200);
361 const flagged = out.json.pages.map((p: any) => p.pageId);
362 expect(flagged).not.toContain(privatePageId);
363 expect(flagged).not.toContain(dupPageIds[3]);
364 });
365});
366
367
368
369
370
371describe("assistant suggest-metadata + apply (T-175)", () => {
372 let suggestionId = "";
373 let suggestedTitle = "";
374
375 it("suggests a title/description grounded in the page (suggestion only)", async () => {
376 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/suggest-metadata`, {
377 pageId: notePageId,
378 });
379 expect(out.status).toBe(200);
380 suggestionId = out.json.suggestionId;
381 suggestedTitle = out.json.suggested.title;
382 expect(suggestedTitle.length).toBeGreaterThan(0);
383 expect(out.json.current.title).toBe("Onboarding Guide");
384
385 const page = await app.prisma.page.findUnique({ where: { id: notePageId }, select: { title: true } });
386 expect(page?.title).toBe("Onboarding Guide");
387 expect(await auditCount("ai.assistant_metadata_suggested", ws.workspaceId)).toBeGreaterThanOrEqual(1);
388 });
389
390 it("applies the suggestion through the normal page PATCH (EDIT re-checked)", async () => {
391 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/apply-metadata`, {
392 suggestionId,
393 fields: { title: suggestedTitle },
394 });
395 expect(out.status).toBe(200);
396 const page = await app.prisma.page.findUnique({ where: { id: notePageId }, select: { title: true } });
397 expect(page?.title).toBe(suggestedTitle);
398 expect(await auditCount("ai.assistant_metadata_applied", ws.workspaceId)).toBeGreaterThanOrEqual(1);
399 });
400
401 it("rejects applying a field NOT in the stored suggestion (no smuggling) → 400", async () => {
402 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/apply-metadata`, {
403 suggestionId,
404 fields: { title: "Totally different title" },
405 });
406 expect(out.status).toBe(400);
407 });
408
409 it("non-member → 404 (no-leak, both suggest and apply)", async () => {
410 const outsider = await createUser(app.request, { name: `Assistant Outsider2 ${RUN}` });
411 const suggest = await call(outsider.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/suggest-metadata`, {
412 pageId: notePageId,
413 });
414 expect(suggest.status).toBe(404);
415 const apply = await call(outsider.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/apply-metadata`, {
416 suggestionId,
417 fields: { title: suggestedTitle },
418 });
419 expect(apply.status).toBe(404);
420 });
421});
422
423
424
425
426
427describe("assistant hostile tool output", () => {
428 it("invalid tool call → retry once → 422, NO task written", async () => {
429 const wsB = await createWorkspace(app.request, authHeaders(ws.owner.accessToken!), `Assistant Hostile ${RUN}`);
430 const arm = await app.request("POST", `/internal/workspaces/${wsB.workspaceId}/ai/mock-assistant`, {
431 headers: { Authorization: `Bearer ${app.internalServiceToken}` },
432 body: { hostile: true },
433 });
434 expect(arm.status).toBe(200);
435 const out = await call(ws.owner.accessToken!, `workspaces/${wsB.workspaceId}/ai/assistant/extract-actions`, {
436 text: "- One item",
437 });
438 expect(out.status).toBe(422);
439 expect(JSON.stringify(out.json)).toMatch(/invalid/i);
440 const tasks = await app.prisma.task.count({ where: { workspaceId: wsB.workspaceId } });
441 expect(tasks).toBe(0);
442 });
443});
444
445
446
447
448
449describe("assistant ledger + limits", () => {
450 it("per-call conversations (`assistant:*`) never appear in the panel list", async () => {
451 const out = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/ai/conversations`, {
452 headers: authHeaders(ws.owner.accessToken!),
453 });
454 expect(out.status).toBe(200);
455 const { data } = (await out.json()) as { data: Array<{ title: string }> };
456 expect(data.some((c) => c.title.startsWith("assistant:"))).toBe(false);
457
458 const hidden = await app.prisma.aiConversation.count({
459 where: { workspaceId: ws.workspaceId, title: { startsWith: "assistant:" } },
460 });
461 expect(hidden).toBeGreaterThanOrEqual(3);
462 });
463
464 it("shared per-user rate window → 429 ai_call_rate_limited", async () => {
465 const statuses: number[] = [];
466 let limited: Record<string, any> | null = null;
467 for (let i = 0; i < 32; i++) {
468 const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/extract-actions`, {
469 text: "- rl item",
470 });
471 statuses.push(out.status);
472 if (out.status === 429) limited = out.json;
473 }
474 expect(statuses).toContain(429);
475 expect(JSON.stringify(limited)).toMatch(/ai_call_rate_limited/);
476 });
477
478 it("budget exhaustion → 402 AI_BUDGET_EXCEEDED (pre-call JSON)", async () => {
479
480
481 const fresh = await createUser(app.request, { name: `Assistant Budget User ${RUN}` });
482 const wsC = await createWorkspace(app.request, authHeaders(ws.owner.accessToken!), `Assistant Budget ${RUN}`);
483 await addMemberViaSeam(app.request, app.internalServiceToken, wsC.workspaceId, fresh.email, "MEMBER");
484 const set = await app.request("POST", `/internal/workspaces/${wsC.workspaceId}/ai/usage-limits`, {
485 headers: { Authorization: `Bearer ${app.internalServiceToken}` },
486 body: { aiMonthlyTokens: 1 },
487 });
488 expect(set.status).toBe(200);
489 const out = await call(fresh.accessToken!, `workspaces/${wsC.workspaceId}/ai/assistant/extract-actions`, {
490 text: "- budget item",
491 });
492 expect(out.status).toBe(402);
493 expect(JSON.stringify(out.json)).toMatch(/AI_BUDGET_EXCEEDED/);
494 const audit = await app.prisma.auditLog.findFirst({
495 where: { workspaceId: wsC.workspaceId, action: "ai.budget_exceeded" },
496 });
497 expect(audit).toBeTruthy();
498 });
499});
500