import { beforeEach, describe, expect, it, vi } from "vitest"; import { PermissionKey, SystemRole, 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[], userRole: SystemRole = SystemRole.ADMIN, ) { return getAvailableAssistantTools(new Set(permissions), userRole).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"); expect(withAdvanced).toContain("get_chargeability_report"); expect(withAdvanced).toContain("get_resource_computation_graph"); expect(withAdvanced).toContain("get_project_computation_graph"); }); 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"); expect(names).not.toContain("get_chargeability_report"); expect(names).not.toContain("get_resource_computation_graph"); expect(names).not.toContain("get_project_computation_graph"); }); it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => { const controllerNames = getToolNames([ PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.CONTROLLER); const userNames = getToolNames([ PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.USER); expect(controllerNames).toContain("get_chargeability_report"); expect(controllerNames).toContain("get_resource_computation_graph"); expect(controllerNames).toContain("get_project_computation_graph"); expect(userNames).not.toContain("get_chargeability_report"); expect(userNames).not.toContain("get_resource_computation_graph"); expect(userNames).not.toContain("get_project_computation_graph"); }); it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => { const managerNames = getToolNames([ PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.MANAGER); const userNames = getToolNames([ PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.USER); const missingAdvancedNames = getToolNames([ PermissionKey.MANAGE_ALLOCATIONS, ], SystemRole.MANAGER); expect(managerNames).toContain("update_timeline_allocation_inline"); expect(managerNames).toContain("apply_timeline_project_shift"); expect(managerNames).toContain("quick_assign_timeline_resource"); expect(managerNames).toContain("batch_quick_assign_timeline_resources"); expect(managerNames).toContain("batch_shift_timeline_allocations"); expect(userNames).not.toContain("update_timeline_allocation_inline"); expect(userNames).not.toContain("apply_timeline_project_shift"); expect(userNames).not.toContain("quick_assign_timeline_resource"); expect(userNames).not.toContain("batch_quick_assign_timeline_resources"); expect(userNames).not.toContain("batch_shift_timeline_allocations"); expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline"); expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource"); }); it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => { const adminNames = getToolNames([], SystemRole.ADMIN); const managerNames = getToolNames([], SystemRole.MANAGER); expect(adminNames).toContain("list_holiday_calendars"); expect(adminNames).toContain("get_holiday_calendar"); expect(adminNames).toContain("preview_resolved_holiday_calendar"); expect(adminNames).toContain("create_holiday_calendar"); expect(managerNames).toContain("list_holiday_calendars"); expect(managerNames).toContain("get_holiday_calendar"); expect(managerNames).toContain("preview_resolved_holiday_calendar"); expect(managerNames).not.toContain("create_holiday_calendar"); expect(managerNames).not.toContain("update_holiday_calendar"); expect(managerNames).not.toContain("delete_holiday_calendar"); expect(managerNames).not.toContain("create_holiday_calendar_entry"); expect(managerNames).not.toContain("update_holiday_calendar_entry"); expect(managerNames).not.toContain("delete_holiday_calendar_entry"); }); it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => { const adminNames = getToolNames([], SystemRole.ADMIN); const managerNames = getToolNames([], SystemRole.MANAGER); expect(adminNames).toContain("list_countries"); expect(adminNames).toContain("get_country"); expect(adminNames).toContain("create_country"); expect(adminNames).toContain("update_country"); expect(adminNames).toContain("create_metro_city"); expect(adminNames).toContain("update_metro_city"); expect(adminNames).toContain("delete_metro_city"); expect(managerNames).toContain("list_countries"); expect(managerNames).toContain("get_country"); expect(managerNames).not.toContain("create_country"); expect(managerNames).not.toContain("update_country"); expect(managerNames).not.toContain("create_metro_city"); expect(managerNames).not.toContain("update_metro_city"); expect(managerNames).not.toContain("delete_metro_city"); }); 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"); expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role"); expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role"); expect(toolDescriptions.get("get_chargeability_report")).toContain("controller/manager/admin"); expect(toolDescriptions.get("get_chargeability_report")).toContain("viewCosts"); expect(toolDescriptions.get("get_resource_computation_graph")).toContain("useAssistantAdvancedTools"); expect(toolDescriptions.get("get_project_computation_graph")).toContain("controller/manager/admin"); expect(toolDescriptions.get("update_timeline_allocation_inline")).toContain("manager/admin"); expect(toolDescriptions.get("apply_timeline_project_shift")).toContain("manageAllocations"); expect(toolDescriptions.get("quick_assign_timeline_resource")).toContain("useAssistantAdvancedTools"); expect(toolDescriptions.get("batch_quick_assign_timeline_resources")).toContain("manageAllocations"); expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin"); }); });