import { beforeEach, describe, expect, it, vi } from "vitest"; import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; import { apiRateLimiter } from "../middleware/rate-limit.js"; import { ASSISTANT_CONFIRMATION_PREFIX, canExecuteMutationTool, clearPendingAssistantApproval, consumePendingAssistantApproval, createPendingAssistantApproval, getAvailableAssistantTools, listPendingAssistantApprovals, peekPendingAssistantApproval, selectAssistantToolsForRequest, } 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); } function getSelectedToolNames( permissions: PermissionKeyValue[], messages: Array<{ role: "user" | "assistant"; content: string }>, userRole: SystemRole = SystemRole.ADMIN, pageContext?: string, ) { return selectAssistantToolsForRequest( getAvailableAssistantTools(new Set(permissions), userRole), messages, pageContext, ).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; }), }, }; } function createMissingApprovalTableError() { return Object.assign( new Error("The table `public.assistant_approvals` does not exist in the current database."), { code: "P2021", meta: { table: "public.assistant_approvals" }, }, ); } describe("assistant router tool gating", () => { let approvalStore = createApprovalStoreMock(); beforeEach(() => { approvalStore = createApprovalStoreMock(); apiRateLimiter.reset(); }); it("hides advanced tools unless the dedicated assistant permission is granted", () => { const withoutAdvanced = getToolNames([ PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS, ]); const withAdvanced = getToolNames([ PermissionKey.VIEW_PLANNING, 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 self-service tools available to plain authenticated users", () => { const userNames = getToolNames([], SystemRole.USER); expect(userNames).toContain("get_current_user"); expect(userNames).toContain("get_dashboard_layout"); expect(userNames).toContain("save_dashboard_layout"); expect(userNames).toContain("get_favorite_project_ids"); expect(userNames).toContain("toggle_favorite_project"); expect(userNames).toContain("get_column_preferences"); expect(userNames).toContain("set_column_preferences"); expect(userNames).toContain("get_mfa_status"); expect(userNames).toContain("list_notifications"); expect(userNames).toContain("get_unread_notification_count"); expect(userNames).toContain("list_tasks"); expect(userNames).toContain("get_task_counts"); expect(userNames).toContain("create_reminder"); expect(userNames).toContain("list_reminders"); expect(userNames).toContain("update_reminder"); expect(userNames).toContain("delete_reminder"); }); it("keeps admin-only user tools hidden from non-admin roles", () => { const adminNames = getToolNames([], SystemRole.ADMIN); const managerNames = getToolNames([], SystemRole.MANAGER); const userNames = getToolNames([], SystemRole.USER); expect(adminNames).toContain("list_users"); expect(adminNames).toContain("get_active_user_count"); expect(adminNames).toContain("create_user"); expect(adminNames).toContain("set_user_password"); expect(adminNames).toContain("update_user_role"); expect(adminNames).toContain("update_user_name"); expect(adminNames).toContain("link_user_resource"); expect(adminNames).toContain("auto_link_users_by_email"); expect(adminNames).toContain("set_user_permissions"); expect(adminNames).toContain("reset_user_permissions"); expect(adminNames).toContain("get_effective_user_permissions"); expect(adminNames).toContain("disable_user_totp"); expect(managerNames).not.toContain("list_users"); expect(managerNames).not.toContain("create_user"); expect(managerNames).not.toContain("set_user_permissions"); expect(managerNames).not.toContain("disable_user_totp"); expect(userNames).not.toContain("list_users"); expect(userNames).not.toContain("get_active_user_count"); expect(userNames).not.toContain("create_user"); expect(userNames).not.toContain("set_user_password"); expect(userNames).not.toContain("update_user_role"); expect(userNames).not.toContain("update_user_name"); expect(userNames).not.toContain("link_user_resource"); expect(userNames).not.toContain("auto_link_users_by_email"); expect(userNames).not.toContain("set_user_permissions"); expect(userNames).not.toContain("reset_user_permissions"); expect(userNames).not.toContain("get_effective_user_permissions"); expect(userNames).not.toContain("disable_user_totp"); }); it("caps the OpenAI tool payload to 128 definitions even for fully privileged admins", () => { const allPermissions = Object.values(PermissionKey); const selectedNames = getSelectedToolNames( allPermissions, [{ role: "user", content: "Bitte gib mir einen Überblick über das System." }], SystemRole.ADMIN, ); expect(selectedNames.length).toBeLessThanOrEqual(128); expect(selectedNames).toContain("get_current_user"); expect(selectedNames).toContain("search_resources"); expect(selectedNames).toContain("search_projects"); }); it("prioritizes holiday and resource tools for German holiday questions", () => { const allPermissions = Object.values(PermissionKey); const selectedNames = getSelectedToolNames( allPermissions, [{ role: "user", content: "Kannst du mir alle Feiertage nennen, die Peter Parker in 2026 zustehen?" }], SystemRole.ADMIN, ); expect(selectedNames.length).toBeLessThanOrEqual(128); expect(selectedNames).toContain("search_resources"); expect(selectedNames).toContain("get_resource"); expect(selectedNames).toContain("get_resource_holidays"); expect(selectedNames).toContain("list_holidays_by_region"); expect(selectedNames).toContain("list_holiday_calendars"); }); it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => { const managerNames = getToolNames([], SystemRole.MANAGER); const adminNames = getToolNames([], SystemRole.ADMIN); const userNames = getToolNames([], SystemRole.USER); expect(managerNames).toContain("list_assignable_users"); expect(managerNames).toContain("create_notification"); expect(managerNames).toContain("create_task_for_user"); expect(managerNames).toContain("assign_task"); expect(managerNames).toContain("send_broadcast"); expect(managerNames).toContain("list_broadcasts"); expect(managerNames).toContain("get_broadcast_detail"); expect(adminNames).toContain("list_assignable_users"); expect(adminNames).toContain("create_task_for_user"); expect(adminNames).toContain("send_broadcast"); expect(userNames).not.toContain("list_assignable_users"); expect(userNames).not.toContain("create_notification"); expect(userNames).not.toContain("create_task_for_user"); expect(userNames).not.toContain("assign_task"); expect(userNames).not.toContain("send_broadcast"); expect(userNames).not.toContain("list_broadcasts"); expect(userNames).not.toContain("get_broadcast_detail"); }); 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("query_change_history"); expect(controllerNames).toContain("get_entity_timeline"); expect(controllerNames).toContain("search_by_skill"); expect(controllerNames).toContain("export_resources_csv"); expect(controllerNames).toContain("export_projects_csv"); expect(controllerNames).toContain("list_audit_log_entries"); expect(controllerNames).toContain("get_audit_log_entry"); expect(controllerNames).toContain("get_audit_log_timeline"); expect(controllerNames).toContain("get_audit_activity_summary"); expect(controllerNames).toContain("get_chargeability_report"); expect(controllerNames).toContain("get_resource_computation_graph"); expect(controllerNames).toContain("get_project_computation_graph"); expect(userNames).not.toContain("query_change_history"); expect(userNames).not.toContain("get_entity_timeline"); expect(userNames).not.toContain("search_by_skill"); expect(userNames).not.toContain("export_resources_csv"); expect(userNames).not.toContain("export_projects_csv"); expect(userNames).not.toContain("list_audit_log_entries"); expect(userNames).not.toContain("get_audit_log_entry"); expect(userNames).not.toContain("get_audit_log_timeline"); expect(userNames).not.toContain("get_audit_activity_summary"); 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 planning read tools behind the explicit planning permission", () => { const userWithoutPlanning = getToolNames([], SystemRole.USER); const userWithPlanning = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER); expect(userWithoutPlanning).not.toContain("list_allocations"); expect(userWithoutPlanning).not.toContain("list_demands"); expect(userWithoutPlanning).not.toContain("list_blueprints"); expect(userWithoutPlanning).not.toContain("list_clients"); expect(userWithoutPlanning).not.toContain("list_roles"); expect(userWithoutPlanning).not.toContain("list_management_levels"); expect(userWithoutPlanning).not.toContain("list_utilization_categories"); expect(userWithoutPlanning).not.toContain("check_resource_availability"); expect(userWithoutPlanning).not.toContain("find_capacity"); expect(userWithoutPlanning).not.toContain("get_staffing_suggestions"); expect(userWithoutPlanning).not.toContain("find_best_project_resource"); expect(userWithPlanning).toContain("list_allocations"); expect(userWithPlanning).toContain("list_demands"); expect(userWithPlanning).toContain("list_blueprints"); expect(userWithPlanning).toContain("list_clients"); expect(userWithPlanning).toContain("list_roles"); expect(userWithPlanning).toContain("list_management_levels"); expect(userWithPlanning).toContain("list_utilization_categories"); expect(userWithPlanning).toContain("check_resource_availability"); expect(userWithPlanning).toContain("find_capacity"); expect(userWithPlanning).not.toContain("get_staffing_suggestions"); expect(userWithPlanning).not.toContain("find_best_project_resource"); }); it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => { const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER); const planningAndCosts = getToolNames([ PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS, ], SystemRole.USER); const planningCostsAndAdvanced = getToolNames([ PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.USER); expect(planningOnly).not.toContain("get_staffing_suggestions"); expect(planningOnly).not.toContain("find_best_project_resource"); expect(planningAndCosts).toContain("get_staffing_suggestions"); expect(planningAndCosts).not.toContain("find_best_project_resource"); expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions"); expect(planningCostsAndAdvanced).toContain("find_best_project_resource"); }); it("keeps controller-only project and dashboard reads hidden from plain users", () => { const controllerNames = getToolNames([ PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.CONTROLLER); const userNames = getToolNames([ PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, PermissionKey.VIEW_PLANNING, ], SystemRole.USER); expect(controllerNames).toContain("search_projects"); expect(controllerNames).toContain("get_project"); expect(controllerNames).toContain("get_statistics"); expect(controllerNames).toContain("get_dashboard_detail"); expect(controllerNames).toContain("get_skill_gaps"); expect(controllerNames).toContain("get_project_health"); expect(controllerNames).toContain("get_budget_forecast"); expect(userNames).not.toContain("search_projects"); expect(userNames).not.toContain("get_project"); expect(userNames).not.toContain("get_statistics"); expect(userNames).not.toContain("get_dashboard_detail"); expect(userNames).not.toContain("get_skill_gaps"); expect(userNames).not.toContain("get_project_health"); expect(userNames).not.toContain("get_budget_forecast"); }); it("requires both controller role and advanced assistant access for timeline detail tools", () => { const controllerWithAdvanced = getToolNames([ PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.CONTROLLER); const controllerWithoutAdvanced = getToolNames([], SystemRole.CONTROLLER); const userWithAdvanced = getToolNames([ PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ], SystemRole.USER); expect(controllerWithAdvanced).toContain("get_timeline_entries_view"); expect(controllerWithAdvanced).toContain("get_timeline_holiday_overlays"); expect(controllerWithAdvanced).toContain("get_project_timeline_context"); expect(controllerWithAdvanced).toContain("preview_project_shift"); expect(controllerWithoutAdvanced).not.toContain("get_timeline_entries_view"); expect(controllerWithoutAdvanced).not.toContain("get_timeline_holiday_overlays"); expect(controllerWithoutAdvanced).not.toContain("get_project_timeline_context"); expect(controllerWithoutAdvanced).not.toContain("preview_project_shift"); expect(userWithAdvanced).not.toContain("get_timeline_entries_view"); expect(userWithAdvanced).not.toContain("get_timeline_holiday_overlays"); expect(userWithAdvanced).not.toContain("get_project_timeline_context"); expect(userWithAdvanced).not.toContain("preview_project_shift"); }); 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 estimate lifecycle mutations behind manager/admin role and their router permissions", () => { const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER); const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER); const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER); expect(managerProjectNames).toContain("create_estimate"); expect(managerProjectNames).toContain("clone_estimate"); expect(managerProjectNames).toContain("update_estimate_draft"); expect(managerProjectNames).toContain("submit_estimate_version"); expect(managerProjectNames).toContain("approve_estimate_version"); expect(managerProjectNames).toContain("create_estimate_revision"); expect(managerProjectNames).toContain("create_estimate_export"); expect(managerProjectNames).toContain("generate_estimate_weekly_phasing"); expect(managerProjectNames).toContain("update_estimate_commercial_terms"); expect(managerProjectNames).not.toContain("create_estimate_planning_handoff"); expect(managerAllocationNames).toContain("create_estimate_planning_handoff"); expect(managerAllocationNames).not.toContain("create_estimate"); expect(userProjectNames).not.toContain("create_estimate"); expect(userProjectNames).not.toContain("clone_estimate"); expect(userProjectNames).not.toContain("update_estimate_draft"); expect(userProjectNames).not.toContain("submit_estimate_version"); expect(userProjectNames).not.toContain("approve_estimate_version"); expect(userProjectNames).not.toContain("create_estimate_revision"); expect(userProjectNames).not.toContain("create_estimate_export"); expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing"); expect(userProjectNames).not.toContain("update_estimate_commercial_terms"); expect(userProjectNames).not.toContain("create_estimate_planning_handoff"); }); it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => { const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER); const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER); const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER); const managerWithoutCosts = getToolNames([], SystemRole.MANAGER); const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER); expect(controllerNames).toContain("search_estimates"); expect(controllerNames).toContain("get_estimate_detail"); expect(controllerNames).toContain("list_estimate_versions"); expect(controllerNames).toContain("get_estimate_version_snapshot"); expect(controllerNames).toContain("get_estimate_weekly_phasing"); expect(controllerNames).toContain("get_estimate_commercial_terms"); expect(controllerWithoutCosts).toContain("search_estimates"); expect(controllerWithoutCosts).not.toContain("get_estimate_detail"); expect(controllerWithoutCosts).toContain("list_estimate_versions"); expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot"); expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing"); expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms"); expect(managerNames).toContain("search_estimates"); expect(managerNames).toContain("get_estimate_detail"); expect(managerNames).toContain("list_estimate_versions"); expect(managerNames).toContain("get_estimate_version_snapshot"); expect(managerNames).toContain("get_estimate_weekly_phasing"); expect(managerNames).toContain("get_estimate_commercial_terms"); expect(managerWithoutCosts).toContain("search_estimates"); expect(managerWithoutCosts).toContain("list_estimate_versions"); expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot"); expect(userNames).not.toContain("search_estimates"); expect(userNames).not.toContain("get_estimate_detail"); expect(userNames).not.toContain("list_estimate_versions"); expect(userNames).not.toContain("get_estimate_version_snapshot"); expect(userNames).not.toContain("get_estimate_weekly_phasing"); expect(userNames).not.toContain("get_estimate_commercial_terms"); }); it("keeps import/dispo parity tools aligned to router roles and permissions", () => { const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER); const controllerNames = getToolNames([], SystemRole.CONTROLLER); const adminNames = getToolNames([], SystemRole.ADMIN); const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER); expect(managerNames).toContain("import_csv_data"); expect(controllerNames).toContain("export_resources_csv"); expect(controllerNames).toContain("export_projects_csv"); expect(adminNames).toContain("list_dispo_import_batches"); expect(adminNames).toContain("get_dispo_import_batch"); expect(adminNames).toContain("stage_dispo_import_batch"); expect(adminNames).toContain("validate_dispo_import_batch"); expect(adminNames).toContain("cancel_dispo_import_batch"); expect(adminNames).toContain("list_dispo_staged_resources"); expect(adminNames).toContain("list_dispo_staged_projects"); expect(adminNames).toContain("list_dispo_staged_assignments"); expect(adminNames).toContain("list_dispo_staged_vacations"); expect(adminNames).toContain("list_dispo_staged_unresolved_records"); expect(adminNames).toContain("resolve_dispo_staged_record"); expect(adminNames).toContain("commit_dispo_import_batch"); expect(userNames).not.toContain("import_csv_data"); expect(userNames).not.toContain("export_resources_csv"); expect(userNames).not.toContain("export_projects_csv"); expect(userNames).not.toContain("list_dispo_import_batches"); expect(userNames).not.toContain("get_dispo_import_batch"); expect(userNames).not.toContain("stage_dispo_import_batch"); expect(userNames).not.toContain("validate_dispo_import_batch"); expect(userNames).not.toContain("list_dispo_staged_resources"); expect(userNames).not.toContain("commit_dispo_import_batch"); }); it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => { const adminNames = getToolNames([], SystemRole.ADMIN); const userNames = getToolNames([], SystemRole.USER); expect(adminNames).toContain("get_system_settings"); expect(adminNames).toContain("update_system_settings"); expect(adminNames).toContain("test_ai_connection"); expect(adminNames).toContain("test_smtp_connection"); expect(adminNames).toContain("test_gemini_connection"); expect(adminNames).toContain("list_system_role_configs"); expect(adminNames).toContain("update_system_role_config"); expect(adminNames).toContain("list_webhooks"); expect(adminNames).toContain("get_webhook"); expect(adminNames).toContain("create_webhook"); expect(adminNames).toContain("update_webhook"); expect(adminNames).toContain("delete_webhook"); expect(adminNames).toContain("test_webhook"); expect(adminNames).toContain("get_ai_configured"); expect(adminNames).toContain("list_system_role_configs"); expect(userNames).not.toContain("get_system_settings"); expect(userNames).not.toContain("update_system_settings"); expect(userNames).not.toContain("test_ai_connection"); expect(userNames).not.toContain("list_system_role_configs"); expect(userNames).not.toContain("update_system_role_config"); expect(userNames).not.toContain("list_webhooks"); expect(userNames).not.toContain("create_webhook"); expect(userNames).toContain("get_ai_configured"); }); it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => { const adminNames = getToolNames([], SystemRole.ADMIN); const managerNames = getToolNames([], SystemRole.MANAGER); const userNames = getToolNames([], SystemRole.USER); 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).not.toContain("list_holiday_calendars"); expect(managerNames).not.toContain("get_holiday_calendar"); expect(managerNames).toContain("preview_resolved_holiday_calendar"); expect(userNames).not.toContain("list_holiday_calendars"); expect(userNames).not.toContain("get_holiday_calendar"); expect(userNames).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); const userNames = getToolNames([], SystemRole.USER); const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER); const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER); expect(adminNames).toContain("list_countries"); 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).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"); expect(userNames).not.toContain("search_resources"); expect(userNames).not.toContain("get_country"); expect(userNames).not.toContain("list_org_units"); expect(userWithResourceOverview).toContain("search_resources"); expect(userWithResourceOverview).toContain("get_country"); expect(userWithResourceOverview).toContain("list_org_units"); expect(userWithManagedResources).toContain("search_resources"); expect(userWithManagedResources).toContain("get_country"); expect(userWithManagedResources).toContain("list_org_units"); }); 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("degrades approval reads gracefully when approval storage is missing", async () => { const missingTableError = createMissingApprovalTableError(); const missingStore = { assistantApproval: { findFirst: vi.fn(async () => { throw missingTableError; }), findMany: vi.fn(async () => { throw missingTableError; }), create: vi.fn(async () => { throw missingTableError; }), updateMany: vi.fn(async () => { throw missingTableError; }), }, }; await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]); await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined(); }); it("returns an explicit error when approval storage is missing for mutation confirmation", async () => { const missingTableError = createMissingApprovalTableError(); const missingStore = { assistantApproval: { findFirst: vi.fn(async () => { throw missingTableError; }), findMany: vi.fn(async () => { throw missingTableError; }), create: vi.fn(async () => { throw missingTableError; }), updateMany: vi.fn(async () => { throw missingTableError; }), }, }; await expect(createPendingAssistantApproval( missingStore, TEST_USER_ID, TEST_CONVERSATION_ID, "create_project", JSON.stringify({ name: "Apollo" }), )).rejects.toThrow("Assistant approval storage is unavailable"); }); 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("create_estimate_planning_handoff")).toContain("manageAllocations"); expect(toolDescriptions.get("get_estimate_detail")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("list_estimate_versions")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("get_estimate_version_snapshot")).toContain("viewCosts"); expect(toolDescriptions.get("get_estimate_weekly_phasing")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("get_estimate_commercial_terms")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("create_vacation")).toContain("authenticated user"); expect(toolDescriptions.get("approve_vacation")).toContain("Manager or admin role"); expect(toolDescriptions.get("reject_vacation")).toContain("Manager or admin role"); expect(toolDescriptions.get("cancel_vacation")).toContain("Users can cancel their own requests"); expect(toolDescriptions.get("set_entitlement")).toContain("Manager or admin role"); expect(toolDescriptions.get("create_role")).toContain("manageRoles"); expect(toolDescriptions.get("update_role")).toContain("manageRoles"); expect(toolDescriptions.get("delete_role")).toContain("manageRoles"); expect(toolDescriptions.get("create_org_unit")).toContain("Admin role"); expect(toolDescriptions.get("update_org_unit")).toContain("Admin role"); expect(toolDescriptions.get("list_users")).toContain("Admin role"); expect(toolDescriptions.get("list_assignable_users")).toContain("Manager or admin role"); expect(toolDescriptions.get("get_current_user")).toContain("authenticated user's own profile"); expect(toolDescriptions.get("create_notification")).toContain("Manager or admin role"); expect(toolDescriptions.get("create_task_for_user")).toContain("Manager or admin role"); expect(toolDescriptions.get("send_broadcast")).toContain("Manager or admin role"); expect(toolDescriptions.get("get_broadcast_detail")).toContain("Manager or admin role"); expect(toolDescriptions.get("create_client")).toContain("manager or admin role"); expect(toolDescriptions.get("update_client")).toContain("manager or admin role"); expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role"); expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role"); expect(toolDescriptions.get("query_change_history")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("get_entity_timeline")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("export_resources_csv")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("export_projects_csv")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("import_csv_data")).toContain("importData"); expect(toolDescriptions.get("import_csv_data")).toContain("manager/admin"); expect(toolDescriptions.get("list_dispo_import_batches")).toContain("Admin role"); expect(toolDescriptions.get("get_dispo_import_batch")).toContain("Admin role"); expect(toolDescriptions.get("stage_dispo_import_batch")).toContain("Admin role"); expect(toolDescriptions.get("validate_dispo_import_batch")).toContain("Admin role"); expect(toolDescriptions.get("commit_dispo_import_batch")).toContain("Always confirm first"); expect(toolDescriptions.get("get_system_settings")).toContain("Admin role"); expect(toolDescriptions.get("update_system_settings")).toContain("Always confirm first"); expect(toolDescriptions.get("get_ai_configured")).toContain("authenticated user"); expect(toolDescriptions.get("list_system_role_configs")).toContain("authenticated user"); expect(toolDescriptions.get("update_system_role_config")).toContain("Admin role"); expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked"); expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first"); expect(toolDescriptions.get("test_webhook")).toContain("Always confirm first"); expect(toolDescriptions.get("list_audit_log_entries")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("get_audit_log_entry")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("get_audit_log_timeline")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("get_audit_activity_summary")).toContain("Controller/manager/admin"); 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"); }); it("aligns assistant tool visibility with router role and permission rules", () => { const managerWithRolePermission = getToolNames( [PermissionKey.MANAGE_ROLES], SystemRole.MANAGER, ); const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER); expect(managerWithRolePermission).toContain("create_role"); expect(managerWithRolePermission).toContain("update_role"); expect(managerWithRolePermission).toContain("delete_role"); expect(managerWithRolePermission).toContain("create_client"); expect(managerWithRolePermission).toContain("update_client"); expect(managerWithRolePermission).not.toContain("create_org_unit"); expect(managerWithRolePermission).not.toContain("update_org_unit"); expect(managerWithoutRolePermission).not.toContain("create_role"); expect(managerWithoutRolePermission).not.toContain("update_role"); expect(managerWithoutRolePermission).not.toContain("delete_role"); expect(managerWithoutRolePermission).toContain("create_client"); expect(managerWithoutRolePermission).toContain("update_client"); const adminWithRolePermission = getToolNames( [PermissionKey.MANAGE_ROLES], SystemRole.ADMIN, ); expect(adminWithRolePermission).toContain("create_org_unit"); expect(adminWithRolePermission).toContain("update_org_unit"); const standardUserTools = getToolNames([], SystemRole.USER); expect(standardUserTools).toContain("get_vacation_balance"); expect(standardUserTools).toContain("create_vacation"); expect(standardUserTools).toContain("cancel_vacation"); expect(standardUserTools).not.toContain("approve_vacation"); expect(standardUserTools).not.toContain("reject_vacation"); expect(standardUserTools).not.toContain("set_entitlement"); const managerVacationTools = getToolNames([], SystemRole.MANAGER); expect(managerVacationTools).toContain("approve_vacation"); expect(managerVacationTools).toContain("reject_vacation"); expect(managerVacationTools).toContain("set_entitlement"); }); it("keeps estimate tool parameter enums aligned with the current estimate schema", () => { const definitionByName = new Map( TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function]), ); const createEstimateStatus = ( definitionByName.get("create_estimate")?.parameters as { properties?: Record; } )?.properties?.status?.enum; const updateEstimateStatus = ( definitionByName.get("update_estimate_draft")?.parameters as { properties?: Record; } )?.properties?.status?.enum; const estimateExportFormats = ( definitionByName.get("create_estimate_export")?.parameters as { properties?: Record; } )?.properties?.format?.enum; expect(createEstimateStatus).toEqual(["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"]); expect(updateEstimateStatus).toEqual(["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"]); expect(estimateExportFormats).toEqual(["XLSX", "CSV", "JSON", "SAP", "MMP"]); }); });