feat(scenario): scope baseline reads to planning and cost audiences

This commit is contained in:
2026-03-30 09:40:07 +02:00
parent 3aac946443
commit 806c028974
2 changed files with 140 additions and 2 deletions
@@ -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<string, unknown>) {
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<string, unknown>,
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,
});
});
});
+5 -2
View File
@@ -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: {