feat(estimate): scope estimate search to controller audience
This commit is contained in:
@@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user