feat(staffing): enforce planning and cost audiences
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user