feat(estimate): scope estimate search to controller audience

This commit is contained in:
2026-03-30 09:44:50 +02:00
parent 806c028974
commit 98502e6cf8
4 changed files with 65 additions and 5 deletions
@@ -530,23 +530,28 @@ describe("assistant router tool gating", () => {
const managerWithoutCosts = getToolNames([], SystemRole.MANAGER); const managerWithoutCosts = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER); const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER);
expect(controllerNames).toContain("search_estimates");
expect(controllerNames).toContain("get_estimate_detail"); expect(controllerNames).toContain("get_estimate_detail");
expect(controllerNames).toContain("list_estimate_versions"); expect(controllerNames).toContain("list_estimate_versions");
expect(controllerNames).toContain("get_estimate_version_snapshot"); expect(controllerNames).toContain("get_estimate_version_snapshot");
expect(controllerNames).toContain("get_estimate_weekly_phasing"); expect(controllerNames).toContain("get_estimate_weekly_phasing");
expect(controllerNames).toContain("get_estimate_commercial_terms"); expect(controllerNames).toContain("get_estimate_commercial_terms");
expect(controllerWithoutCosts).toContain("search_estimates");
expect(controllerWithoutCosts).not.toContain("get_estimate_detail"); expect(controllerWithoutCosts).not.toContain("get_estimate_detail");
expect(controllerWithoutCosts).toContain("list_estimate_versions"); expect(controllerWithoutCosts).toContain("list_estimate_versions");
expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot"); expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot");
expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing"); expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing");
expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms"); expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms");
expect(managerNames).toContain("search_estimates");
expect(managerNames).toContain("get_estimate_detail"); expect(managerNames).toContain("get_estimate_detail");
expect(managerNames).toContain("list_estimate_versions"); expect(managerNames).toContain("list_estimate_versions");
expect(managerNames).toContain("get_estimate_version_snapshot"); expect(managerNames).toContain("get_estimate_version_snapshot");
expect(managerNames).toContain("get_estimate_weekly_phasing"); expect(managerNames).toContain("get_estimate_weekly_phasing");
expect(managerNames).toContain("get_estimate_commercial_terms"); expect(managerNames).toContain("get_estimate_commercial_terms");
expect(managerWithoutCosts).toContain("search_estimates");
expect(managerWithoutCosts).toContain("list_estimate_versions"); expect(managerWithoutCosts).toContain("list_estimate_versions");
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot"); 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("get_estimate_detail");
expect(userNames).not.toContain("list_estimate_versions"); expect(userNames).not.toContain("list_estimate_versions");
expect(userNames).not.toContain("get_estimate_version_snapshot"); expect(userNames).not.toContain("get_estimate_version_snapshot");
@@ -2,6 +2,7 @@ import {
EstimateExportFormat, EstimateExportFormat,
EstimateStatus, EstimateStatus,
EstimateVersionStatus, EstimateVersionStatus,
PermissionKey,
SystemRole, SystemRole,
} from "@capakraken/shared"; } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -62,6 +63,24 @@ function createProtectedCaller(db: Record<string, unknown>) {
}); });
} }
function createProtectedCallerWithOverrides(
db: Record<string, unknown>,
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 // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -107,11 +126,33 @@ describe("estimate router", () => {
// ─── list ────────────────────────────────────────────────────────────────── // ─── list ──────────────────────────────────────────────────────────────────
describe("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 () => { it("returns all estimates without filters", async () => {
const findMany = vi.fn().mockResolvedValue([baseEstimate]); const findMany = vi.fn().mockResolvedValue([baseEstimate]);
const db = { estimate: { findMany } }; const db = { estimate: { findMany } };
const caller = createProtectedCaller(db); const caller = createManagerCaller(db);
const result = await caller.list({}); const result = await caller.list({});
expect(findMany).toHaveBeenCalled(); expect(findMany).toHaveBeenCalled();
@@ -122,7 +163,7 @@ describe("estimate router", () => {
const findMany = vi.fn().mockResolvedValue([]); const findMany = vi.fn().mockResolvedValue([]);
const db = { estimate: { findMany } }; const db = { estimate: { findMany } };
const caller = createProtectedCaller(db); const caller = createManagerCaller(db);
await caller.list({ projectId: "project_1" }); await caller.list({ projectId: "project_1" });
expect(findMany).toHaveBeenCalledWith( expect(findMany).toHaveBeenCalledWith(
@@ -136,7 +177,7 @@ describe("estimate router", () => {
const findMany = vi.fn().mockResolvedValue([]); const findMany = vi.fn().mockResolvedValue([]);
const db = { estimate: { findMany } }; const db = { estimate: { findMany } };
const caller = createProtectedCaller(db); const caller = createManagerCaller(db);
await caller.list({ status: EstimateStatus.DRAFT }); await caller.list({ status: EstimateStatus.DRAFT });
expect(findMany).toHaveBeenCalledWith( expect(findMany).toHaveBeenCalledWith(
@@ -150,7 +191,7 @@ describe("estimate router", () => {
const findMany = vi.fn().mockResolvedValue([]); const findMany = vi.fn().mockResolvedValue([]);
const db = { estimate: { findMany } }; const db = { estimate: { findMany } };
const caller = createProtectedCaller(db); const caller = createManagerCaller(db);
await caller.list({ query: "search" }); await caller.list({ query: "search" });
expect(findMany).toHaveBeenCalledWith( 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 ─────────────────────────────────────────────────────────────── // ─── getById ───────────────────────────────────────────────────────────────
+1
View File
@@ -239,6 +239,7 @@ const PLANNING_READ_TOOLS = new Set([
const CONTROLLER_ONLY_TOOLS = new Set([ const CONTROLLER_ONLY_TOOLS = new Set([
"search_projects", "search_projects",
"get_project", "get_project",
"search_estimates",
"get_timeline_entries_view", "get_timeline_entries_view",
"get_timeline_holiday_overlays", "get_timeline_holiday_overlays",
"get_project_timeline_context", "get_project_timeline_context",
+1 -1
View File
@@ -245,7 +245,7 @@ async function autoFillDemandLineRates(
} }
export const estimateRouter = createTRPCRouter({ export const estimateRouter = createTRPCRouter({
list: protectedProcedure list: controllerProcedure
.input(EstimateListFiltersSchema.default({})) .input(EstimateListFiltersSchema.default({}))
.query(async ({ ctx, input }) => .query(async ({ ctx, input }) =>
listEstimates( listEstimates(