feat(scenario): scope baseline reads to planning and cost audiences
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { calculateAllocation } from "@capakraken/engine/allocation";
|
import { calculateAllocation } from "@capakraken/engine/allocation";
|
||||||
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
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 { createAuditEntry } from "../lib/audit.js";
|
||||||
import {
|
import {
|
||||||
calculateEffectiveAvailableHours,
|
calculateEffectiveAvailableHours,
|
||||||
@@ -41,9 +42,11 @@ export const scenarioRouter = createTRPCRouter({
|
|||||||
/**
|
/**
|
||||||
* Returns current allocations/costs for a project — the baseline for comparison.
|
* Returns current allocations/costs for a project — the baseline for comparison.
|
||||||
*/
|
*/
|
||||||
getProjectBaseline: protectedProcedure
|
getProjectBaseline: planningReadProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
||||||
|
|
||||||
const project = await ctx.db.project.findUnique({
|
const project = await ctx.db.project.findUnique({
|
||||||
where: { id: input.projectId },
|
where: { id: input.projectId },
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
Reference in New Issue
Block a user