import { beforeEach, describe, expect, it, vi } from "vitest"; import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; import { ASSISTANT_CONFIRMATION_PREFIX, canExecuteMutationTool, clearPendingAssistantApproval, consumePendingAssistantApproval, createPendingAssistantApproval, getAvailableAssistantTools, listPendingAssistantApprovals, peekPendingAssistantApproval, } from "../router/assistant.js"; import { TOOL_DEFINITIONS } from "../router/assistant-tools.js"; function getToolNames(permissions: PermissionKeyValue[]) { return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name); } const TEST_USER_ID = "assistant-test-user"; const TEST_CONVERSATION_ID = "assistant-test-conversation"; function createApprovalStoreMock() { const records = new Map(); return { assistantApproval: { findFirst: vi.fn(async ({ where, orderBy, }: { where: { id?: string; userId?: string; conversationId?: string; status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED"; }; orderBy?: { createdAt: "desc" | "asc" }; }) => { const matches = [...records.values()] .filter((record) => ( (!where.id || record.id === where.id) && (!where.userId || record.userId === where.userId) && (!where.conversationId || record.conversationId === where.conversationId) && (!where.status || record.status === where.status) )) .sort((a, b) => ( orderBy?.createdAt === "asc" ? a.createdAt.getTime() - b.createdAt.getTime() : b.createdAt.getTime() - a.createdAt.getTime() )); return matches[0] ?? null; }), findMany: vi.fn(async ({ where, orderBy, }: { where: { userId?: string; conversationId?: string; status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED"; expiresAt?: { lte?: Date; gt?: Date }; }; orderBy?: { createdAt: "desc" | "asc" }; }) => ( [...records.values()] .filter((record) => ( (!where.userId || record.userId === where.userId) && (!where.conversationId || record.conversationId === where.conversationId) && (!where.status || record.status === where.status) && (!where.expiresAt?.lte || record.expiresAt <= where.expiresAt.lte) && (!where.expiresAt?.gt || record.expiresAt > where.expiresAt.gt) )) .sort((a, b) => ( orderBy?.createdAt === "asc" ? a.createdAt.getTime() - b.createdAt.getTime() : b.createdAt.getTime() - a.createdAt.getTime() )) )), create: vi.fn(async ({ data, }: { data: { userId: string; conversationId: string; toolName: string; toolArguments: string; summary: string; createdAt: Date; expiresAt: Date; }; }) => { const record = { id: `approval-${records.size + 1}`, ...data, status: "PENDING" as const, approvedAt: null, cancelledAt: null, updatedAt: data.createdAt, }; records.set(record.id, record); return record; }), updateMany: vi.fn(async ({ where, data, }: { where: { id?: string; userId?: string; conversationId?: string; status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED"; expiresAt?: { lte?: Date; gt?: Date }; }; data: Partial<{ status: "APPROVED" | "CANCELLED" | "EXPIRED"; cancelledAt: Date; approvedAt: Date; }>; }) => { let count = 0; for (const [id, record] of records.entries()) { if (where.id && record.id !== where.id) continue; if (where.userId && record.userId !== where.userId) continue; if (where.conversationId && record.conversationId !== where.conversationId) continue; if (where.status && record.status !== where.status) continue; if (where.expiresAt?.lte && record.expiresAt > where.expiresAt.lte) continue; if (where.expiresAt?.gt && record.expiresAt <= where.expiresAt.gt) continue; records.set(id, { ...record, ...data, updatedAt: new Date(), }); count += 1; } return { count }; }), update: vi.fn(async ({ where, data, }: { where: { id: string }; data: { status: "APPROVED"; approvedAt: Date; }; }) => { const record = records.get(where.id); if (!record) throw new Error("Record not found"); const next = { ...record, ...data, updatedAt: new Date(), }; records.set(where.id, next); return next; }), }, }; } describe("assistant router tool gating", () => { let approvalStore = createApprovalStoreMock(); beforeEach(() => { approvalStore = createApprovalStoreMock(); }); it("hides advanced tools unless the dedicated assistant permission is granted", () => { const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]); const withAdvanced = getToolNames([ PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ]); expect(withoutAdvanced).not.toContain("find_best_project_resource"); expect(withAdvanced).toContain("find_best_project_resource"); }); it("keeps user administration tools behind manageUsers", () => { const withoutManageUsers = getToolNames([]); const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]); expect(withoutManageUsers).not.toContain("list_users"); expect(withManageUsers).toContain("list_users"); }); it("continues to hide cost-aware advanced tools when viewCosts is missing", () => { const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]); expect(names).not.toContain("find_best_project_resource"); }); it("blocks mutation tools until the user confirms a prior assistant summary", () => { expect(canExecuteMutationTool([ { role: "user", content: "Lege bitte ein Projekt an" }, ], "create_project")).toBe(false); expect(canExecuteMutationTool([ { role: "user", content: "Lege bitte ein Projekt an" }, { role: "assistant", content: "Ich werde jetzt das Projekt erstellen." }, { role: "user", content: "ja" }, ], "create_project")).toBe(false); expect(canExecuteMutationTool([ { role: "user", content: "Lege bitte ein Projekt an" }, { role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} Ich werde das Projekt \"Apollo\" in DRAFT anlegen. Bitte bestätigen.` }, { role: "user", content: "ja, bitte ausführen" }, ], "create_project")).toBe(true); }); it("requires a matching server-side pending approval for mutation execution when provided", async () => { const pendingApproval = await createPendingAssistantApproval( approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID, "create_project", JSON.stringify({ name: "Apollo", status: "DRAFT" }), ); expect(canExecuteMutationTool([ { role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` }, { role: "user", content: "ja" }, ], "create_project", pendingApproval)).toBe(true); expect(canExecuteMutationTool([ { role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` }, { role: "user", content: "ja" }, ], "delete_project", pendingApproval)).toBe(false); }); it("stores and consumes pending approvals independently from chat text", async () => { const approval = await createPendingAssistantApproval( approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID, "create_project", JSON.stringify({ name: "Gelddruckmaschine", status: "DRAFT" }), ); await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({ id: approval.id, toolName: "create_project", summary: expect.stringContaining("create project"), }); await expect(consumePendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({ id: approval.id, toolName: "create_project", }); await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); }); it("expires stale pending approvals", async () => { await createPendingAssistantApproval( approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID, "create_project", JSON.stringify({ name: "Apollo" }), { ttlMs: -1 }, ); await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); }); it("clears pending approvals for cancellation semantics", async () => { await createPendingAssistantApproval( approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID, "create_project", JSON.stringify({ name: "Apollo" }), ); await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID); await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); }); it("isolates pending approvals by conversation", async () => { const otherConversationId = `${TEST_CONVERSATION_ID}-other`; await createPendingAssistantApproval( approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID, "create_project", JSON.stringify({ name: "Apollo" }), ); await createPendingAssistantApproval( approvalStore, TEST_USER_ID, otherConversationId, "create_project", JSON.stringify({ name: "Hermes" }), ); await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID); await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, otherConversationId)).resolves.toMatchObject({ toolName: "create_project", summary: expect.stringContaining("Hermes"), }); }); it("lists only still-pending approvals for the current user across conversations", async () => { const otherConversationId = `${TEST_CONVERSATION_ID}-other`; await createPendingAssistantApproval( approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID, "create_project", JSON.stringify({ name: "Apollo" }), ); await createPendingAssistantApproval( approvalStore, TEST_USER_ID, otherConversationId, "create_project", JSON.stringify({ name: "Hermes" }), ); const cancelled = await createPendingAssistantApproval( approvalStore, TEST_USER_ID, `${TEST_CONVERSATION_ID}-cancelled`, "create_project", JSON.stringify({ name: "Cancelled" }), ); await approvalStore.assistantApproval.updateMany({ where: { id: cancelled.id, userId: TEST_USER_ID, status: "PENDING" }, data: { status: "CANCELLED", cancelledAt: new Date() }, }); await createPendingAssistantApproval( approvalStore, "other-user", `${TEST_CONVERSATION_ID}-foreign`, "create_project", JSON.stringify({ name: "Foreign" }), ); await createPendingAssistantApproval( approvalStore, TEST_USER_ID, `${TEST_CONVERSATION_ID}-expired`, "create_project", JSON.stringify({ name: "Expired" }), { ttlMs: -1 }, ); const approvals = await listPendingAssistantApprovals(approvalStore, TEST_USER_ID); const approvalSummaries = approvals.map((approval) => approval.summary).join(" "); expect(approvals).toHaveLength(2); expect([...approvals.map((approval) => approval.conversationId)].sort()).toEqual([ otherConversationId, TEST_CONVERSATION_ID, ].sort()); expect(approvals.every((approval) => approval.userId === TEST_USER_ID)).toBe(true); expect(approvalSummaries).toContain("Apollo"); expect(approvalSummaries).toContain("Hermes"); expect(approvalSummaries).not.toContain("Cancelled"); expect(approvalSummaries).not.toContain("Expired"); expect(approvalSummaries).not.toContain("Foreign"); }); it("does not require confirmation for read-only assistant tools", () => { expect(canExecuteMutationTool([ { role: "user", content: "Zeig mir meine Notifications" }, ], "list_notifications")).toBe(true); }); it("keeps assistant tool descriptions aligned with runtime permissions", () => { const toolDescriptions = new Map( TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function.description]), ); expect(toolDescriptions.get("create_estimate")).toContain("manageProjects"); expect(toolDescriptions.get("set_entitlement")).toContain("manageVacations"); expect(toolDescriptions.get("create_org_unit")).toContain("manageResources"); expect(toolDescriptions.get("update_org_unit")).toContain("manageResources"); expect(toolDescriptions.get("list_users")).toContain("manageUsers"); expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects"); expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects"); }); });