feat(staffing): enforce planning and cost audiences

This commit is contained in:
2026-03-30 09:36:38 +02:00
parent a960d43ed1
commit 3aac946443
4 changed files with 248 additions and 13 deletions
@@ -208,8 +208,12 @@ describe("assistant router tool gating", () => {
});
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
const withoutAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
]);
const withAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
]);
@@ -382,9 +386,35 @@ describe("assistant router tool gating", () => {
expect(userWithoutPlanning).not.toContain("list_allocations");
expect(userWithoutPlanning).not.toContain("list_demands");
expect(userWithoutPlanning).not.toContain("check_resource_availability");
expect(userWithoutPlanning).not.toContain("find_capacity");
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
expect(userWithPlanning).toContain("list_allocations");
expect(userWithPlanning).toContain("list_demands");
expect(userWithPlanning).toContain("check_resource_availability");
expect(userWithPlanning).toContain("find_capacity");
expect(userWithPlanning).not.toContain("get_staffing_suggestions");
expect(userWithPlanning).not.toContain("find_best_project_resource");
});
it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => {
const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
const planningAndCosts = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
], SystemRole.USER);
const planningCostsAndAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER);
expect(planningOnly).not.toContain("get_staffing_suggestions");
expect(planningOnly).not.toContain("find_best_project_resource");
expect(planningAndCosts).toContain("get_staffing_suggestions");
expect(planningAndCosts).not.toContain("find_best_project_resource");
expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions");
expect(planningCostsAndAdvanced).toContain("find_best_project_resource");
});
it("keeps controller-only project and dashboard reads hidden from plain users", () => {
@@ -1,5 +1,5 @@
import { listAssignmentBookings } from "@capakraken/application";
import { SystemRole } from "@capakraken/shared";
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { staffingRouter } from "../router/staffing.js";
import { createCallerFactory } from "../trpc.js";
@@ -33,6 +33,23 @@ const createCaller = createCallerFactory(staffingRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(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: {
granted: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS],
},
},
});
}
function createAuthenticatedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
@@ -47,6 +64,24 @@ function createProtectedCaller(db: Record<string, unknown>) {
});
}
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,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleResource(overrides: Record<string, unknown> = {}) {
@@ -73,6 +108,164 @@ function sampleResource(overrides: Record<string, unknown> = {}) {
};
}
describe("staffing router authorization", () => {
const planningWindow = {
resourceId: "res_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
};
const projectRankingWindow = {
projectId: "project_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
};
it.each([
{
name: "getSuggestions",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
hoursPerDay: 8,
}),
},
{
name: "getProjectStaffingSuggestions",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.getProjectStaffingSuggestions({
projectId: projectRankingWindow.projectId,
}),
},
{
name: "searchCapacity",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.searchCapacity({
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
}),
},
{
name: "analyzeUtilization",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.analyzeUtilization({
resourceId: planningWindow.resourceId,
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
}),
},
{
name: "findCapacity",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.findCapacity({
resourceId: planningWindow.resourceId,
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
}),
},
{
name: "findBestProjectResource",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.findBestProjectResource({
projectId: projectRankingWindow.projectId,
startDate: projectRankingWindow.startDate,
endDate: projectRankingWindow.endDate,
}),
},
{
name: "getBestProjectResourceDetail",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.getBestProjectResourceDetail({
projectId: projectRankingWindow.projectId,
startDate: projectRankingWindow.startDate,
endDate: projectRankingWindow.endDate,
}),
},
])("requires planning read access for $name", async ({ invoke }) => {
const caller = createAuthenticatedCaller({});
await expect(invoke(caller)).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
});
it("allows explicit viewPlanning overrides to search capacity", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCallerWithOverrides(db, {
granted: [PermissionKey.VIEW_PLANNING],
});
const result = await caller.searchCapacity({
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
});
expect(result).toEqual({
period: "2026-04-01 to 2026-04-02",
minHoursFilter: 4,
results: [],
totalFound: 0,
});
});
it("does not treat viewCosts as a substitute for viewPlanning on staffing reads", async () => {
const caller = createProtectedCallerWithOverrides({}, {
granted: [PermissionKey.VIEW_COSTS],
});
await expect(caller.searchCapacity({
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
});
it.each([
{
name: "getSuggestions",
invoke: (caller: ReturnType<typeof createProtectedCallerWithOverrides>) => caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
hoursPerDay: 8,
}),
},
{
name: "getProjectStaffingSuggestions",
invoke: (caller: ReturnType<typeof createProtectedCallerWithOverrides>) => caller.getProjectStaffingSuggestions({
projectId: projectRankingWindow.projectId,
}),
},
{
name: "findBestProjectResource",
invoke: (caller: ReturnType<typeof createProtectedCallerWithOverrides>) => caller.findBestProjectResource({
projectId: projectRankingWindow.projectId,
startDate: projectRankingWindow.startDate,
endDate: projectRankingWindow.endDate,
}),
},
{
name: "getBestProjectResourceDetail",
invoke: (caller: ReturnType<typeof createProtectedCallerWithOverrides>) => caller.getBestProjectResourceDetail({
projectId: projectRankingWindow.projectId,
startDate: projectRankingWindow.startDate,
endDate: projectRankingWindow.endDate,
}),
},
])("requires viewCosts for $name", async ({ invoke }) => {
const caller = createProtectedCallerWithOverrides({}, {
granted: [PermissionKey.VIEW_PLANNING],
});
await expect(invoke(caller)).rejects.toMatchObject({
code: "FORBIDDEN",
message: `Permission required: ${PermissionKey.VIEW_COSTS}`,
});
});
});
// ─── getSuggestions ──────────────────────────────────────────────────────────
describe("staffing.getSuggestions", () => {