From bca6abf2bb930938939dc4d6bd2e8db986edc3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 00:05:33 +0200 Subject: [PATCH] test(api): cover assistant tool policy parity --- .../assistant-tool-policy-access.test.ts | 284 ++++++++++++++++++ .../assistant-tool-policy-admin.test.ts | 233 ++++++++++++++ .../assistant-tool-policy-planning.test.ts | 140 +++++++++ .../assistant-tools-registry-access.test.ts | 71 +++++ 4 files changed, 728 insertions(+) create mode 100644 packages/api/src/__tests__/assistant-tool-policy-access.test.ts create mode 100644 packages/api/src/__tests__/assistant-tool-policy-admin.test.ts create mode 100644 packages/api/src/__tests__/assistant-tool-policy-planning.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-registry-access.test.ts diff --git a/packages/api/src/__tests__/assistant-tool-policy-access.test.ts b/packages/api/src/__tests__/assistant-tool-policy-access.test.ts new file mode 100644 index 0000000..5297484 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tool-policy-access.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; +import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js"; + +function getToolNames( + permissions: PermissionKeyValue[], + userRole: SystemRole = SystemRole.ADMIN, +) { + return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name); +} + +describe("assistant tool policy access", () => { + 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("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 entity-scoped comment tools available to plain authenticated users", () => { + const userNames = getToolNames([], SystemRole.USER); + + expect(userNames).toContain("list_comments"); + expect(userNames).toContain("create_comment"); + expect(userNames).toContain("resolve_comment"); + }); + + 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("get_blueprint"); + 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("get_blueprint"); + 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, + PermissionKey.VIEW_PLANNING, + ], 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(controllerNames).toContain("get_budget_status"); + expect(controllerNames).toContain("get_shoring_ratio"); + 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"); + expect(userNames).not.toContain("get_budget_status"); + expect(userNames).not.toContain("get_shoring_ratio"); + }); + + it("keeps legacy controller-only analysis and report tools hidden from plain users", () => { + const controllerNames = getToolNames([ + PermissionKey.VIEW_COSTS, + PermissionKey.VIEW_PLANNING, + ], SystemRole.CONTROLLER); + const userNames = getToolNames([ + PermissionKey.VIEW_COSTS, + PermissionKey.VIEW_PLANNING, + ], SystemRole.USER); + + expect(controllerNames).toContain("detect_anomalies"); + expect(controllerNames).toContain("get_insights_summary"); + expect(controllerNames).toContain("run_report"); + expect(controllerNames).toContain("lookup_rate"); + expect(controllerNames).toContain("simulate_scenario"); + expect(controllerNames).toContain("generate_project_narrative"); + expect(controllerNames).toContain("list_rate_cards"); + expect(controllerNames).toContain("resolve_rate"); + expect(userNames).not.toContain("detect_anomalies"); + expect(userNames).not.toContain("get_insights_summary"); + expect(userNames).not.toContain("run_report"); + expect(userNames).not.toContain("lookup_rate"); + expect(userNames).not.toContain("simulate_scenario"); + expect(userNames).not.toContain("generate_project_narrative"); + expect(userNames).not.toContain("list_rate_cards"); + expect(userNames).not.toContain("resolve_rate"); + }); + + it("keeps cost-sensitive legacy rate tools hidden without viewCosts", () => { + const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER); + const controllerWithCosts = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER); + + expect(controllerWithoutCosts).not.toContain("list_rate_cards"); + expect(controllerWithoutCosts).not.toContain("resolve_rate"); + expect(controllerWithCosts).toContain("list_rate_cards"); + expect(controllerWithCosts).toContain("resolve_rate"); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tool-policy-admin.test.ts b/packages/api/src/__tests__/assistant-tool-policy-admin.test.ts new file mode 100644 index 0000000..d0c8cb1 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tool-policy-admin.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; +import { getAvailableAssistantTools } from "../router/assistant-tool-policy.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); +} + +describe("assistant tool policy admin parity", () => { + 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 managerNames = getToolNames([], SystemRole.MANAGER); + const userNames = getToolNames([], SystemRole.USER); + + expect(adminNames).toContain("get_system_settings"); + expect(adminNames).toContain("update_system_settings"); + expect(adminNames).toContain("clear_stored_runtime_secrets"); + 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(managerNames).not.toContain("get_system_settings"); + expect(managerNames).not.toContain("update_system_settings"); + expect(managerNames).not.toContain("clear_stored_runtime_secrets"); + expect(managerNames).not.toContain("test_ai_connection"); + expect(managerNames).not.toContain("get_ai_configured"); + expect(managerNames).not.toContain("list_system_role_configs"); + expect(managerNames).not.toContain("update_system_role_config"); + expect(managerNames).not.toContain("list_webhooks"); + expect(managerNames).not.toContain("create_webhook"); + + expect(userNames).not.toContain("get_system_settings"); + expect(userNames).not.toContain("update_system_settings"); + expect(userNames).not.toContain("clear_stored_runtime_secrets"); + expect(userNames).not.toContain("test_ai_connection"); + expect(userNames).not.toContain("get_ai_configured"); + 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"); + }); + + it("keeps client deletion admin-only while still allowing manager client maintenance", () => { + const adminNames = getToolNames([], SystemRole.ADMIN); + const managerNames = getToolNames([], SystemRole.MANAGER); + const userNames = getToolNames([], SystemRole.USER); + + expect(adminNames).toContain("create_client"); + expect(adminNames).toContain("update_client"); + expect(adminNames).toContain("delete_client"); + expect(managerNames).toContain("create_client"); + expect(managerNames).toContain("update_client"); + expect(managerNames).not.toContain("delete_client"); + expect(userNames).not.toContain("create_client"); + expect(userNames).not.toContain("update_client"); + expect(userNames).not.toContain("delete_client"); + }); + + 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("attaches explicit access metadata to legacy monolithic tools with restricted visibility", () => { + const toolAccess = new Map( + TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.access]), + ); + + expect(toolAccess.get("run_report")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("simulate_scenario")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("detect_anomalies")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("get_insights_summary")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("lookup_rate")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + requiredPermissions: [PermissionKey.VIEW_COSTS], + }); + expect(toolAccess.get("list_rate_cards")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + requiresCostView: true, + }); + expect(toolAccess.get("resolve_rate")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + requiresCostView: true, + }); + expect(toolAccess.get("import_csv_data")).toEqual({ + requiredPermissions: [PermissionKey.IMPORT_DATA], + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER], + }); + }); + + 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"); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tool-policy-planning.test.ts b/packages/api/src/__tests__/assistant-tool-policy-planning.test.ts new file mode 100644 index 0000000..6ae1e0e --- /dev/null +++ b/packages/api/src/__tests__/assistant-tool-policy-planning.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; +import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js"; + +function getToolNames( + permissions: PermissionKeyValue[], + userRole: SystemRole = SystemRole.ADMIN, +) { + return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name); +} + +describe("assistant tool policy planning flows", () => { + 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("exposes self-service timeline tools to authenticated users without advanced assistant access", () => { + const userNames = getToolNames([], SystemRole.USER); + const viewerNames = getToolNames([], SystemRole.VIEWER); + const controllerNames = getToolNames([], SystemRole.CONTROLLER); + + expect(userNames).toContain("get_my_timeline_entries_view"); + expect(userNames).toContain("get_my_timeline_holiday_overlays"); + expect(viewerNames).toContain("get_my_timeline_entries_view"); + expect(viewerNames).toContain("get_my_timeline_holiday_overlays"); + expect(controllerNames).toContain("get_my_timeline_entries_view"); + expect(controllerNames).toContain("get_my_timeline_holiday_overlays"); + }); + + 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"); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-registry-access.test.ts b/packages/api/src/__tests__/assistant-tools-registry-access.test.ts new file mode 100644 index 0000000..b81ac6f --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-registry-access.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +import { + executeTool, + getAvailableAssistantToolsForContext, +} from "../router/assistant-tools.js"; + +function createToolContext( + userRole: SystemRole, + permissions: PermissionKey[] = [], +) { + return { + db: {} as never, + userId: "user_1", + userRole, + permissions: new Set(permissions), + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +} + +describe("assistant tool registry access", () => { + it("derives admin-only settings tools directly from tool metadata", () => { + const adminNames = getAvailableAssistantToolsForContext(new Set(), SystemRole.ADMIN) + .map((tool) => tool.function.name); + const managerNames = getAvailableAssistantToolsForContext(new Set(), SystemRole.MANAGER) + .map((tool) => tool.function.name); + + expect(adminNames).toContain("get_ai_configured"); + expect(adminNames).toContain("list_system_role_configs"); + expect(managerNames).not.toContain("get_ai_configured"); + expect(managerNames).not.toContain("list_system_role_configs"); + }); + + it("keeps cost-sensitive registry tools hidden until viewCosts is granted", () => { + const managerWithoutCosts = getAvailableAssistantToolsForContext( + new Set(), + SystemRole.MANAGER, + ).map((tool) => tool.function.name); + const managerWithCosts = getAvailableAssistantToolsForContext( + new Set([PermissionKey.VIEW_COSTS]), + SystemRole.MANAGER, + ).map((tool) => tool.function.name); + + expect(managerWithoutCosts).not.toContain("get_budget_forecast"); + expect(managerWithoutCosts).not.toContain("lookup_rate"); + expect(managerWithCosts).toContain("get_budget_forecast"); + expect(managerWithCosts).toContain("lookup_rate"); + }); + + it("enforces metadata-derived permission checks before executing cost tools", async () => { + const result = await executeTool( + "lookup_rate", + JSON.stringify({ chapter: "Animation" }), + createToolContext(SystemRole.MANAGER), + ); + + expect(JSON.parse(result.content)).toEqual({ + error: `Permission denied: you need the "${PermissionKey.VIEW_COSTS}" permission to perform this action.`, + }); + }); +});