diff --git a/packages/api/src/__tests__/scenario-router.test.ts b/packages/api/src/__tests__/scenario-router.test.ts new file mode 100644 index 0000000..341ec1e --- /dev/null +++ b/packages/api/src/__tests__/scenario-router.test.ts @@ -0,0 +1,135 @@ +import { OrderType, PermissionKey, SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { scenarioRouter } from "../router/scenario.js"; +import { createCallerFactory } from "../trpc.js"; + +vi.mock("../lib/resource-capacity.js", () => ({ + calculateEffectiveAvailableHours: vi.fn(), + calculateEffectiveBookedHours: vi.fn(), + loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), +})); + +const createCaller = createCallerFactory(scenarioRouter); + +function createAuthenticatedCaller(db: Record) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: null, + }, + }); +} + +function createProtectedCallerWithOverrides( + db: Record, + overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, +) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: overrides, + }, + }); +} + +describe("scenario router authorization", () => { + it("requires planning read access for getProjectBaseline", async () => { + const caller = createAuthenticatedCaller({}); + + await expect(caller.getProjectBaseline({ + projectId: "project_1", + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + }); + + it("does not treat viewCosts as a substitute for viewPlanning on getProjectBaseline", async () => { + const caller = createProtectedCallerWithOverrides({}, { + granted: [PermissionKey.VIEW_COSTS], + }); + + await expect(caller.getProjectBaseline({ + projectId: "project_1", + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + }); + + it("requires viewCosts for getProjectBaseline", async () => { + const caller = createProtectedCallerWithOverrides({}, { + granted: [PermissionKey.VIEW_PLANNING], + }); + + await expect(caller.getProjectBaseline({ + projectId: "project_1", + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: `Permission required: ${PermissionKey.VIEW_COSTS}`, + }); + }); + + it("allows getProjectBaseline with both planning and cost permissions", async () => { + const project = { + id: "project_1", + name: "Project One", + shortCode: "PRJ-001", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-30T00:00:00.000Z"), + budgetCents: 125_000_00, + orderType: OrderType.CHARGEABLE, + }; + const db = { + project: { + findUnique: vi.fn().mockResolvedValue(project), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createProtectedCallerWithOverrides(db, { + granted: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS], + }); + const result = await caller.getProjectBaseline({ + projectId: "project_1", + }); + + expect(db.project.findUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + select: { + id: true, + name: true, + shortCode: true, + startDate: true, + endDate: true, + budgetCents: true, + orderType: true, + }, + }); + expect(result).toEqual({ + project, + assignments: [], + demands: [], + totalCostCents: 0, + totalHours: 0, + budgetCents: project.budgetCents, + }); + }); +}); diff --git a/packages/api/src/router/scenario.ts b/packages/api/src/router/scenario.ts index 00ba4c8..d75cc85 100644 --- a/packages/api/src/router/scenario.ts +++ b/packages/api/src/router/scenario.ts @@ -1,8 +1,9 @@ import { calculateAllocation } from "@capakraken/engine/allocation"; +import { PermissionKey } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { createTRPCRouter, controllerProcedure, protectedProcedure } from "../trpc.js"; +import { createTRPCRouter, controllerProcedure, planningReadProcedure, requirePermission } from "../trpc.js"; import { createAuditEntry } from "../lib/audit.js"; import { calculateEffectiveAvailableHours, @@ -41,9 +42,11 @@ export const scenarioRouter = createTRPCRouter({ /** * Returns current allocations/costs for a project — the baseline for comparison. */ - getProjectBaseline: protectedProcedure + getProjectBaseline: planningReadProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.VIEW_COSTS); + const project = await ctx.db.project.findUnique({ where: { id: input.projectId }, select: {