fix(blueprint): require planning access for detailed reads

This commit is contained in:
2026-03-30 11:55:43 +02:00
parent 7aa32f8a5c
commit 3a29ce4332
5 changed files with 102 additions and 7 deletions
@@ -391,6 +391,7 @@ describe("assistant router tool gating", () => {
expect(userWithoutPlanning).not.toContain("list_allocations");
expect(userWithoutPlanning).not.toContain("list_demands");
expect(userWithoutPlanning).not.toContain("list_blueprints");
expect(userWithoutPlanning).not.toContain("get_blueprint");
expect(userWithoutPlanning).not.toContain("list_clients");
expect(userWithoutPlanning).not.toContain("list_roles");
expect(userWithoutPlanning).not.toContain("list_management_levels");
@@ -402,6 +403,7 @@ describe("assistant router tool gating", () => {
expect(userWithPlanning).toContain("list_allocations");
expect(userWithPlanning).toContain("list_demands");
expect(userWithPlanning).toContain("list_blueprints");
expect(userWithPlanning).toContain("get_blueprint");
expect(userWithPlanning).toContain("list_clients");
expect(userWithPlanning).toContain("list_roles");
expect(userWithPlanning).toContain("list_management_levels");
@@ -1,4 +1,4 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { BlueprintTarget, PermissionKey, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { blueprintRouter } from "../router/blueprint.js";
import { clientRouter } from "../router/client.js";
@@ -73,6 +73,99 @@ describe("master-data router authorization", () => {
});
});
it("requires planning read access for blueprint list and detailed reads", async () => {
const findMany = vi.fn();
const findUnique = vi.fn();
const findFirst = vi.fn();
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
blueprint: {
findMany,
findUnique,
findFirst,
},
}));
await expect(caller.list({ isActive: true })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
await expect(caller.getByIdentifier({ identifier: "Consulting Blueprint" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
await expect(caller.getById({ id: "bp_1" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
expect(findMany).not.toHaveBeenCalled();
expect(findUnique).not.toHaveBeenCalled();
expect(findFirst).not.toHaveBeenCalled();
});
it("allows blueprint list and detailed reads for users with planning access", async () => {
const listFindMany = vi.fn().mockResolvedValue([
{
id: "bp_1",
name: "Consulting Blueprint",
target: "PROJECT",
description: "Default consulting setup",
isActive: true,
},
]);
const getByIdFindUnique = vi.fn()
.mockResolvedValueOnce({
id: "bp_1",
name: "Consulting Blueprint",
target: "PROJECT",
description: "Default consulting setup",
fieldDefs: [{ key: "market", type: "text" }],
defaults: { market: "EU" },
validationRules: [],
rolePresets: [],
isActive: true,
})
.mockResolvedValueOnce(null);
const getByIdentifierFindFirst = vi.fn().mockResolvedValue({
id: "bp_1",
name: "Consulting Blueprint",
target: "PROJECT",
description: "Default consulting setup",
fieldDefs: [{ key: "market", type: "text" }],
defaults: { market: "EU" },
validationRules: [],
rolePresets: [],
isActive: true,
});
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
blueprint: {
findMany: listFindMany,
findUnique: getByIdFindUnique,
findFirst: getByIdentifierFindFirst,
},
}, {
granted: [PermissionKey.VIEW_PLANNING],
}));
const listResult = await caller.list({ target: BlueprintTarget.PROJECT, isActive: true });
const byIdResult = await caller.getById({ id: "bp_1" });
const byIdentifierResult = await caller.getByIdentifier({ identifier: "Consulting Blueprint" });
expect(listResult).toHaveLength(1);
expect(byIdResult.name).toBe("Consulting Blueprint");
expect(byIdentifierResult.fieldDefs).toEqual([{ key: "market", type: "text" }]);
expect(listFindMany).toHaveBeenCalledWith({
where: { target: "PROJECT", isActive: true },
orderBy: { name: "asc" },
});
expect(getByIdFindUnique).toHaveBeenCalledWith({
where: { id: "bp_1" },
});
expect(getByIdentifierFindFirst).toHaveBeenCalledWith({
where: { name: { equals: "Consulting Blueprint", mode: "insensitive" } },
});
});
it("keeps country lists available to authenticated users as safe lookup data", async () => {
const findMany = vi.fn().mockResolvedValue([
{
+1
View File
@@ -230,6 +230,7 @@ const PLANNING_READ_TOOLS = new Set([
"list_allocations",
"list_demands",
"list_blueprints",
"get_blueprint",
"list_clients",
"list_roles",
"list_management_levels",
+3 -3
View File
@@ -18,7 +18,7 @@ export const blueprintRouter = createTRPCRouter({
});
}),
list: protectedProcedure
list: planningReadProcedure
.input(
z.object({
target: z.nativeEnum(BlueprintTarget).optional(),
@@ -35,7 +35,7 @@ export const blueprintRouter = createTRPCRouter({
});
}),
getById: protectedProcedure
getById: planningReadProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const blueprint = await findUniqueOrThrow(
@@ -82,7 +82,7 @@ export const blueprintRouter = createTRPCRouter({
return blueprint;
}),
getByIdentifier: protectedProcedure
getByIdentifier: planningReadProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();