From 98502e6cf8c895c3d642579997bda66701d16a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 09:44:50 +0200 Subject: [PATCH] feat(estimate): scope estimate search to controller audience --- .../src/__tests__/assistant-router.test.ts | 5 ++ .../api/src/__tests__/estimate-router.test.ts | 62 +++++++++++++++++-- packages/api/src/router/assistant.ts | 1 + packages/api/src/router/estimate.ts | 2 +- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index 8b32bb8..bd98bc5 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -530,23 +530,28 @@ describe("assistant router tool gating", () => { 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"); diff --git a/packages/api/src/__tests__/estimate-router.test.ts b/packages/api/src/__tests__/estimate-router.test.ts index 5131471..147deba 100644 --- a/packages/api/src/__tests__/estimate-router.test.ts +++ b/packages/api/src/__tests__/estimate-router.test.ts @@ -2,6 +2,7 @@ import { EstimateExportFormat, EstimateStatus, EstimateVersionStatus, + PermissionKey, SystemRole, } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; @@ -62,6 +63,24 @@ function createProtectedCaller(db: Record) { }); } +function createProtectedCallerWithOverrides( + db: Record, + overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, +) { + return createCaller({ + session: { + user: { email: "viewer@example.com", name: "Viewer", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_3", + systemRole: SystemRole.USER, + permissionOverrides: overrides, + }, + }); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -107,11 +126,33 @@ describe("estimate router", () => { // ─── list ────────────────────────────────────────────────────────────────── describe("list", () => { + it("requires controller access", async () => { + const caller = createProtectedCaller({}); + + await expect(caller.list({})).rejects.toThrow( + expect.objectContaining({ + code: "FORBIDDEN", + message: "Controller access required", + }), + ); + }); + + it("allows controllers to list estimates", async () => { + const findMany = vi.fn().mockResolvedValue([baseEstimate]); + const db = { estimate: { findMany } }; + + const caller = createControllerCaller(db); + const result = await caller.list({}); + + expect(findMany).toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); + it("returns all estimates without filters", async () => { const findMany = vi.fn().mockResolvedValue([baseEstimate]); const db = { estimate: { findMany } }; - const caller = createProtectedCaller(db); + const caller = createManagerCaller(db); const result = await caller.list({}); expect(findMany).toHaveBeenCalled(); @@ -122,7 +163,7 @@ describe("estimate router", () => { const findMany = vi.fn().mockResolvedValue([]); const db = { estimate: { findMany } }; - const caller = createProtectedCaller(db); + const caller = createManagerCaller(db); await caller.list({ projectId: "project_1" }); expect(findMany).toHaveBeenCalledWith( @@ -136,7 +177,7 @@ describe("estimate router", () => { const findMany = vi.fn().mockResolvedValue([]); const db = { estimate: { findMany } }; - const caller = createProtectedCaller(db); + const caller = createManagerCaller(db); await caller.list({ status: EstimateStatus.DRAFT }); expect(findMany).toHaveBeenCalledWith( @@ -150,7 +191,7 @@ describe("estimate router", () => { const findMany = vi.fn().mockResolvedValue([]); const db = { estimate: { findMany } }; - const caller = createProtectedCaller(db); + const caller = createManagerCaller(db); await caller.list({ query: "search" }); expect(findMany).toHaveBeenCalledWith( @@ -161,6 +202,19 @@ describe("estimate router", () => { }), ); }); + + it("does not grant estimate listing through standalone viewCosts overrides", async () => { + const caller = createProtectedCallerWithOverrides({}, { + granted: [PermissionKey.VIEW_COSTS], + }); + + await expect(caller.list({})).rejects.toThrow( + expect.objectContaining({ + code: "FORBIDDEN", + message: "Controller access required", + }), + ); + }); }); // ─── getById ─────────────────────────────────────────────────────────────── diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index f32e8a9..76b546b 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -239,6 +239,7 @@ const PLANNING_READ_TOOLS = new Set([ const CONTROLLER_ONLY_TOOLS = new Set([ "search_projects", "get_project", + "search_estimates", "get_timeline_entries_view", "get_timeline_holiday_overlays", "get_project_timeline_context", diff --git a/packages/api/src/router/estimate.ts b/packages/api/src/router/estimate.ts index ca1c80a..15ccf9b 100644 --- a/packages/api/src/router/estimate.ts +++ b/packages/api/src/router/estimate.ts @@ -245,7 +245,7 @@ async function autoFillDemandLineRates( } export const estimateRouter = createTRPCRouter({ - list: protectedProcedure + list: controllerProcedure .input(EstimateListFiltersSchema.default({})) .query(async ({ ctx, input }) => listEstimates(