/** * T-172…T-175 (A-010) — AI Knowledge Assistant integration suite. * * Full stack (AppModule + Postgres + Redis) against the deterministic * `MockProvider` (`AI_PROVIDER=mock`; the `mode:"assistant"` branch emits a * deterministic tool call per `action`). All five capabilities are plain-JSON * one-shots, so each test is a simple request → assert round-trip. * * Covers (spec §15 + SECURITY_REVIEW §4.8 — one security negative PER * capability): * - extract-actions: notes → real tasks via TasksService (as the requester); * GUEST (no EDIT) → 403; empty notes → 400; hostile mock → 422, no write. * - generate-faqs: visible pages → FAQ draft page (H2/paragraph per Q/A); * a page the caller lacks VIEW on is NEVER read (dropped pre-prompt); * a non-member → 404 no-leak. * - find-duplicates: candidate pool (most-recent pages, per-page VIEW * re-check); the NONE-granted page is never in the results; forged ids * from the model are DROPPED (never fabricated into the answer). * - find-stale: backdated pool (server updatedAt filter) + per-page VIEW * re-check; the NONE-granted page is never flagged. * - suggest-metadata + apply: suggestion → normal page PATCH (EDIT * re-checked); non-member → 404; apply of a field not in the suggestion * → 400 (no smuggling); ownership-scoped 404 for a foreign suggestion. * - hidden per-call conversations (`assistant:*`) never list in the panel. * - rate limit (shared ai.calls window) → 429; budget exhaustion → 402. */ // Config must be fixed BEFORE the app boots (loadEnv + the workspace cache). process.env.AI_PROVIDER = "mock"; process.env.SEARCH_VECTOR_DIMENSION = "8"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; const RUN = `ast${Date.now().toString(36)}`; type User = Awaited>; interface Ws { workspaceId: string; owner: User; member: User; // GUEST (VIEW) editor: User; // MEMBER (EDIT) — the faqs write-path negative } // Main workspace: owner (EDIT) + member (GUEST/VIEW). `privatePage` is the // member's explicit-NONE page — the per-capability security negative. let ws: Ws; let notePageId = ""; // the generate-faqs source page (owner-visible) let privatePageId = ""; // NONE-granted to the member (drop target) let dupPageIds: string[] = []; // find-duplicates pool let stalePageIds: string[] = []; // find-stale pool (backdated) beforeAll(async () => { app = await createTestApp(); const owner = await createUser(app.request, { name: `Assistant Owner ${RUN}` }); const member = await createUser(app.request, { name: `Assistant Member ${RUN}` }); const editor = await createUser(app.request, { name: `Assistant Editor ${RUN}` }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), `Assistant WS ${RUN}`); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "GUEST"); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, editor.email, "MEMBER"); ws = { workspaceId, owner, member, editor }; // --- generate-faqs source page (owner) with a numbered-source body --- const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(owner.accessToken!), body: { title: "Onboarding Guide" }, }); expect(pageRes.status).toBe(201); notePageId = ((await pageRes.json()) as { id: string }).id; const blockRes = await app.request("POST", `/api/v1/pages/${notePageId}/blocks`, { headers: authHeaders(owner.accessToken!), body: { type: "PARAGRAPH", content: { text: "Set up your account, invite the team, and configure the workspace settings." } }, }); expect(blockRes.status).toBe(201); // --- the member's explicit-NONE page (drop target for every retrieval) --- const privRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(owner.accessToken!), body: { title: `Classified ${RUN}` }, }); privatePageId = ((await privRes.json()) as { id: string }).id; await app.request("POST", `/api/v1/pages/${privatePageId}/blocks`, { headers: authHeaders(owner.accessToken!), body: { type: "PARAGRAPH", content: { text: `Secret fact ${RUN} that must never leak.` } }, }); await app.prisma.permission.create({ data: { workspaceId, granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: privatePageId, level: "NONE", }, }); // The EDITOR (MEMBER) is also explicitly denied the private page — the // generate-faqs write-path negative (a caller who CAN write but must not // read this page). await app.prisma.permission.create({ data: { workspaceId, granteeType: "user", granteeId: editor.userId, resourceType: "page", resourceId: privatePageId, level: "NONE", }, }); // --- find-duplicates pool: 4 owner pages (3 visible to the member) --- for (const i of [1, 2, 3, 4]) { const r = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(owner.accessToken!), body: { title: `Dup Pool ${RUN} p${i}` }, }); dupPageIds.push(((await r.json()) as { id: string }).id); await app.request("POST", `/api/v1/pages/${dupPageIds[i - 1]}/blocks`, { headers: authHeaders(owner.accessToken!), body: { type: "PARAGRAPH", content: { text: `Dup body ${i} ${RUN}.` } }, }); } // The 4th pool page is the NONE page for the member (shared drop target). await app.prisma.permission.create({ data: { workspaceId, granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: dupPageIds[3]!, level: "NONE", }, }); // --- find-stale pool: 3 pages backdated > 90 days (visible to the member) --- for (const i of [1, 2, 3]) { const r = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(owner.accessToken!), body: { title: `Stale Pool ${RUN} s${i}` }, }); stalePageIds.push(((await r.json()) as { id: string }).id); await app.request("POST", `/api/v1/pages/${stalePageIds[i - 1]}/blocks`, { headers: authHeaders(owner.accessToken!), body: { type: "PARAGRAPH", content: { text: `Stale body ${i} ${RUN}.` } }, }); } await app.prisma.page.updateMany({ where: { id: { in: stalePageIds } }, data: { updatedAt: new Date(Date.now() - 200 * 86_400_000) }, }); // The private page is the OLDEST in the pool (stable order: the mock flags // the FIRST visible candidates → it is only ever flagged for the owner, who // can see it; for the member/editor the per-page VIEW re-check drops it). await app.prisma.page.update({ where: { id: privatePageId }, data: { updatedAt: new Date(Date.now() - 250 * 86_400_000) }, }); }, 180_000); afterAll(async () => { await app?.close(); }); // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- async function call( token: string, path: string, body: Record, workspaceId?: string, ): Promise<{ status: number; json: Record }> { const res = await app.request("POST", `/api/v1/${path.replace(/^workspaces/, "workspaces")}`, { headers: authHeaders(token), body, }); const text = await res.text(); let json: Record = {}; try { json = JSON.parse(text); } catch { json = { raw: text }; } return { status: res.status, json }; } async function auditCount(action: string, workspaceId: string): Promise { return app.prisma.auditLog.count({ where: { workspaceId, action } }); } // --------------------------------------------------------------------------- // 1. extract-actions // --------------------------------------------------------------------------- describe("assistant extract-actions (T-172)", () => { it("creates real tasks from the notes' bullet lines (as the requester)", async () => { const before = await auditCount("ai.assistant_extract_actions", ws.workspaceId); const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/extract-actions`, { text: "Team sync:\n- Ship the whiteboard export\n- Review the Q3 budget\n- Update the onboarding doc\n\nNo further items.", }); expect(out.status).toBe(200); expect(out.json.tasks).toHaveLength(3); expect(out.json.tasks[0].title).toBe("Ship the whiteboard export"); expect(out.json.tasks[1].title).toBe("Review the Q3 budget"); expect(out.json.tasks[2].title).toBe("Update the onboarding doc"); // Real tasks exist (TasksService sidecar + task PAGE), status defaults to todo. const rows = await app.prisma.task.findMany({ where: { workspaceId: ws.workspaceId, title: { in: out.json.tasks.map((t: any) => t.title) } }, }); expect(rows).toHaveLength(3); for (const row of rows) { expect(row.status).toBe("todo"); const page = await app.prisma.page.findUnique({ where: { id: row.pageId }, select: { kind: true } }); expect(page?.kind).toBe("task"); } // Listed in the requester's task inbox. const list = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/tasks?scope=all`, { headers: authHeaders(ws.owner.accessToken!), }); expect(list.status).toBe(200); const titles = ((await list.json()) as { data: Array<{ title: string }> }).data.map((t) => t.title); expect(titles).toContain("Ship the whiteboard export"); // Audited. expect(await auditCount("ai.assistant_extract_actions", ws.workspaceId)).toBe(before + 1); }); it("GUEST (no EDIT) → 403 (the capability is EDIT-gated)", async () => { const out = await call(ws.member.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/extract-actions`, { text: "- Do something", }); expect(out.status).toBe(403); }); it("empty/missing notes → 400 VALIDATION_ERROR", async () => { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/extract-actions`, { text: " " }); expect(out.status).toBe(400); }); }); // --------------------------------------------------------------------------- // 2. generate-faqs // --------------------------------------------------------------------------- describe("assistant generate-faqs (T-173)", () => { it("builds an FAQ draft page (H2 question + paragraph answer per FAQ)", async () => { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/generate-faqs`, { pageIds: [notePageId], }); expect(out.status).toBe(200); expect(out.json.faqCount).toBe(1); const pageId = out.json.page.id; const page = await app.prisma.page.findUnique({ where: { id: pageId } }); expect(page?.workspaceId).toBe(ws.workspaceId); // Title block + 1 heading + 1 paragraph = 3 blocks. const blocks = await app.prisma.block.findMany({ where: { pageId, deletedAt: null }, orderBy: { position: "asc" } }); expect(blocks.map((b) => b.type)).toEqual(["PARAGRAPH", "HEADING_2", "PARAGRAPH"]); expect(JSON.stringify(blocks[2]!.content)).toMatch(/per \[1\]/); }); it("never reads a page the caller lacks VIEW on (the NONE page is dropped pre-prompt)", async () => { // The editor (EDIT) sees notePageId but NOT privatePageId (explicit // NONE) — so the write path runs, proving the drop happened at // RETRIEVAL (the prompt never contained the secret). const out = await call(ws.editor.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/generate-faqs`, { pageIds: [notePageId, privatePageId], }); expect(out.status).toBe(200); const pageId = out.json.page.id; const blocks = await app.prisma.block.findMany({ where: { pageId, deletedAt: null } }); const text = JSON.stringify(blocks.map((b) => b.content)); expect(text).not.toContain(`Secret fact ${RUN}`); }); it("non-member → 403 (the AI surface asserts the workspace first, like ai-writing)", async () => { const outsider = await createUser(app.request, { name: `Assistant Outsider ${RUN}` }); const out = await call(outsider.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/generate-faqs`, { pageIds: [notePageId], }); expect(out.status).toBe(403); }); }); // --------------------------------------------------------------------------- // 3. find-duplicates // --------------------------------------------------------------------------- describe("assistant find-duplicates (T-174)", () => { it("groups the most-recent pages (candidate pool, permission-filtered)", async () => { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-duplicates`, {}); expect(out.status).toBe(200); expect(out.json.considered).toBeGreaterThanOrEqual(4); // The mock groups visible candidates pairwise → at least one group. expect(out.json.groups.length).toBeGreaterThanOrEqual(1); const idsInGroups = out.json.groups.flatMap((g: any) => g.pages.map((p: any) => p.pageId)); expect(idsInGroups).toContain(dupPageIds[0]); }); it("never returns the NONE-granted page for the member (security negative)", async () => { const out = await call(ws.member.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-duplicates`, {}); expect(out.status).toBe(200); const idsInGroups = out.json.groups.flatMap((g: any) => g.pages.map((p: any) => p.pageId)); expect(idsInGroups).not.toContain(dupPageIds[3]); expect(idsInGroups).not.toContain(privatePageId); }); it("drops model-emitted ids that are not in the retrieved set (no fabricated refs)", async () => { const arm = await app.request("POST", `/internal/workspaces/${ws.workspaceId}/ai/mock-assistant`, { headers: { Authorization: `Bearer ${app.internalServiceToken}` }, body: { forgeId: true }, }); expect(arm.status).toBe(200); try { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-duplicates`, {}); expect(out.status).toBe(200); const idsInGroups = out.json.groups.flatMap((g: any) => g.pages.map((p: any) => p.pageId)); expect(idsInGroups).not.toContain("forged-id"); // The audited row records the drop. const audit = await app.prisma.auditLog.findFirst({ where: { workspaceId: ws.workspaceId, action: "ai.assistant_duplicates_found" }, orderBy: { createdAt: "desc" }, }); expect(audit).toBeTruthy(); } finally { await import("@factory/api/src/modules/ai/gateway-provider").then((m) => m.resetWorkspaceCache()); } }); }); // --------------------------------------------------------------------------- // 4. find-stale // --------------------------------------------------------------------------- describe("assistant find-stale (T-174)", () => { it("flags stale pages from the backdated pool (server updatedAt filter)", async () => { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-stale`, { days: 90 }); expect(out.status).toBe(200); // The mock flags the first two visible candidates; all 3 backdated pages // are in the pool → 2 flagged, all with titles from the pool. expect(out.json.considered).toBeGreaterThanOrEqual(2); expect(out.json.pages.length).toBeGreaterThanOrEqual(1); const flagged = new Set(out.json.pages.map((p: any) => p.pageId)); for (const p of out.json.pages) { expect(stalePageIds).toContain(p.pageId); expect(p.title.length).toBeGreaterThan(0); expect(p.reason.length).toBeGreaterThan(0); } expect(flagged.size).toBe(out.json.pages.length); }); it("never flags the NONE-granted page for the member (security negative)", async () => { // The private page is NOT backdated — but even if it were, the per-page // VIEW re-check drops it before the model sees it. Assert absence. const out = await call(ws.member.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/find-stale`, { days: 365 }); expect(out.status).toBe(200); const flagged = out.json.pages.map((p: any) => p.pageId); expect(flagged).not.toContain(privatePageId); expect(flagged).not.toContain(dupPageIds[3]); }); }); // --------------------------------------------------------------------------- // 5. suggest-metadata + apply // --------------------------------------------------------------------------- describe("assistant suggest-metadata + apply (T-175)", () => { let suggestionId = ""; let suggestedTitle = ""; it("suggests a title/description grounded in the page (suggestion only)", async () => { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/suggest-metadata`, { pageId: notePageId, }); expect(out.status).toBe(200); suggestionId = out.json.suggestionId; suggestedTitle = out.json.suggested.title; expect(suggestedTitle.length).toBeGreaterThan(0); expect(out.json.current.title).toBe("Onboarding Guide"); // Nothing is written yet. const page = await app.prisma.page.findUnique({ where: { id: notePageId }, select: { title: true } }); expect(page?.title).toBe("Onboarding Guide"); expect(await auditCount("ai.assistant_metadata_suggested", ws.workspaceId)).toBeGreaterThanOrEqual(1); }); it("applies the suggestion through the normal page PATCH (EDIT re-checked)", async () => { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/apply-metadata`, { suggestionId, fields: { title: suggestedTitle }, }); expect(out.status).toBe(200); const page = await app.prisma.page.findUnique({ where: { id: notePageId }, select: { title: true } }); expect(page?.title).toBe(suggestedTitle); expect(await auditCount("ai.assistant_metadata_applied", ws.workspaceId)).toBeGreaterThanOrEqual(1); }); it("rejects applying a field NOT in the stored suggestion (no smuggling) → 400", async () => { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/apply-metadata`, { suggestionId, fields: { title: "Totally different title" }, }); expect(out.status).toBe(400); }); it("non-member → 404 (no-leak, both suggest and apply)", async () => { const outsider = await createUser(app.request, { name: `Assistant Outsider2 ${RUN}` }); const suggest = await call(outsider.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/suggest-metadata`, { pageId: notePageId, }); expect(suggest.status).toBe(404); const apply = await call(outsider.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/apply-metadata`, { suggestionId, fields: { title: suggestedTitle }, }); expect(apply.status).toBe(404); }); }); // --------------------------------------------------------------------------- // hostile mock → 422, no write (extract-actions) // --------------------------------------------------------------------------- describe("assistant hostile tool output", () => { it("invalid tool call → retry once → 422, NO task written", async () => { const wsB = await createWorkspace(app.request, authHeaders(ws.owner.accessToken!), `Assistant Hostile ${RUN}`); const arm = await app.request("POST", `/internal/workspaces/${wsB.workspaceId}/ai/mock-assistant`, { headers: { Authorization: `Bearer ${app.internalServiceToken}` }, body: { hostile: true }, }); expect(arm.status).toBe(200); const out = await call(ws.owner.accessToken!, `workspaces/${wsB.workspaceId}/ai/assistant/extract-actions`, { text: "- One item", }); expect(out.status).toBe(422); expect(JSON.stringify(out.json)).toMatch(/invalid/i); const tasks = await app.prisma.task.count({ where: { workspaceId: wsB.workspaceId } }); expect(tasks).toBe(0); }); }); // --------------------------------------------------------------------------- // hidden conversations + rate limit + budget // --------------------------------------------------------------------------- describe("assistant ledger + limits", () => { it("per-call conversations (`assistant:*`) never appear in the panel list", async () => { const out = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/ai/conversations`, { headers: authHeaders(ws.owner.accessToken!), }); expect(out.status).toBe(200); const { data } = (await out.json()) as { data: Array<{ title: string }> }; expect(data.some((c) => c.title.startsWith("assistant:"))).toBe(false); // …but the rows exist (the ledger is durable). const hidden = await app.prisma.aiConversation.count({ where: { workspaceId: ws.workspaceId, title: { startsWith: "assistant:" } }, }); expect(hidden).toBeGreaterThanOrEqual(3); }); it("shared per-user rate window → 429 ai_call_rate_limited", async () => { const statuses: number[] = []; let limited: Record | null = null; for (let i = 0; i < 32; i++) { const out = await call(ws.owner.accessToken!, `workspaces/${ws.workspaceId}/ai/assistant/extract-actions`, { text: "- rl item", }); statuses.push(out.status); if (out.status === 429) limited = out.json; } expect(statuses).toContain(429); expect(JSON.stringify(limited)).toMatch(/ai_call_rate_limited/); }); it("budget exhaustion → 402 AI_BUDGET_EXCEEDED (pre-call JSON)", async () => { // A FRESH member (fresh per-user rate window — the shared ai.calls window // is per-user; the budget is per-WORKSPACE). const fresh = await createUser(app.request, { name: `Assistant Budget User ${RUN}` }); const wsC = await createWorkspace(app.request, authHeaders(ws.owner.accessToken!), `Assistant Budget ${RUN}`); await addMemberViaSeam(app.request, app.internalServiceToken, wsC.workspaceId, fresh.email, "MEMBER"); const set = await app.request("POST", `/internal/workspaces/${wsC.workspaceId}/ai/usage-limits`, { headers: { Authorization: `Bearer ${app.internalServiceToken}` }, body: { aiMonthlyTokens: 1 }, }); expect(set.status).toBe(200); const out = await call(fresh.accessToken!, `workspaces/${wsC.workspaceId}/ai/assistant/extract-actions`, { text: "- budget item", }); expect(out.status).toBe(402); expect(JSON.stringify(out.json)).toMatch(/AI_BUDGET_EXCEEDED/); const audit = await app.prisma.auditLog.findFirst({ where: { workspaceId: wsC.workspaceId, action: "ai.budget_exceeded" }, }); expect(audit).toBeTruthy(); }); });