feat(org-unit): scope structural reads to resource overview

This commit is contained in:
2026-03-30 10:17:57 +02:00
parent 65fe7ce04f
commit 2b514ea962
4 changed files with 166 additions and 6 deletions
@@ -649,6 +649,7 @@ describe("assistant router tool gating", () => {
const managerNames = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([], SystemRole.USER);
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER);
const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER);
expect(adminNames).toContain("list_countries");
expect(adminNames).toContain("create_country");
@@ -668,6 +669,9 @@ describe("assistant router tool gating", () => {
expect(userWithResourceOverview).toContain("search_resources");
expect(userWithResourceOverview).toContain("get_country");
expect(userWithResourceOverview).toContain("list_org_units");
expect(userWithManagedResources).toContain("search_resources");
expect(userWithManagedResources).toContain("get_country");
expect(userWithManagedResources).toContain("list_org_units");
});
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
@@ -23,6 +23,33 @@ function createProtectedContext(
}
describe("master-data router authorization", () => {
it("keeps country lists available to authenticated users as safe lookup data", async () => {
const findMany = vi.fn().mockResolvedValue([
{
id: "country_de",
code: "DE",
name: "Germany",
isActive: true,
dailyWorkingHours: 8,
metroCities: [{ id: "city_ber", name: "Berlin", countryId: "country_de" }],
},
]);
const caller = createCallerFactory(countryRouter)(createProtectedContext({
country: {
findMany,
},
}));
const result = await caller.list({ isActive: true });
expect(result).toHaveLength(1);
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { isActive: true },
include: { metroCities: { orderBy: { name: "asc" } } },
orderBy: { name: "asc" },
}));
});
it("keeps minimal country lookups available to authenticated users", async () => {
const findFirst = vi.fn().mockResolvedValue({
id: "country_de",
@@ -50,6 +77,31 @@ describe("master-data router authorization", () => {
expect(findFirst).toHaveBeenCalled();
});
it("keeps metro-city lookups available to authenticated users", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "city_muc",
name: "Munich",
countryId: "country_de",
});
const caller = createCallerFactory(countryRouter)(createProtectedContext({
metroCity: {
findUnique,
},
}));
const result = await caller.getCityById({ id: "city_muc" });
expect(result).toEqual({
id: "city_muc",
name: "Munich",
countryId: "country_de",
});
expect(findUnique).toHaveBeenCalledWith({
where: { id: "city_muc" },
select: { id: true, name: true, countryId: true },
});
});
it("requires resource overview access for detailed country reads with resource counts", async () => {
const caller = createCallerFactory(countryRouter)(createProtectedContext({
country: {
@@ -99,6 +151,31 @@ describe("master-data router authorization", () => {
}));
});
it("allows detailed country reads for users with manage-resources access", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "country_de",
code: "DE",
name: "Germany",
isActive: true,
dailyWorkingHours: 8,
scheduleRules: null,
metroCities: [],
_count: { resources: 4 },
});
const caller = createCallerFactory(countryRouter)(createProtectedContext({
country: {
findUnique,
},
}, {
granted: [PermissionKey.MANAGE_RESOURCES],
}));
const result = await caller.getById({ id: "country_de" });
expect(result._count.resources).toBe(4);
expect(findUnique).toHaveBeenCalled();
});
it("keeps minimal org-unit lookups available to authenticated users", async () => {
const findFirst = vi.fn()
.mockResolvedValueOnce(null)
@@ -127,6 +204,26 @@ describe("master-data router authorization", () => {
});
});
it("requires resource overview access for org-unit list and tree reads", async () => {
const findMany = vi.fn();
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
orgUnit: {
findMany,
},
}));
await expect(caller.list({ level: 5, isActive: true })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Resource overview access required",
});
await expect(caller.getTree({ isActive: true })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Resource overview access required",
});
expect(findMany).not.toHaveBeenCalled();
});
it("requires resource overview access for detailed org-unit reads with staffing counts", async () => {
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
orgUnit: {
@@ -172,4 +269,62 @@ describe("master-data router authorization", () => {
include: { _count: { select: { resources: true } } },
}));
});
it("allows org-unit list and tree reads for users with resource overview access", async () => {
const listFindMany = vi.fn().mockResolvedValue([
{
id: "ou_1",
name: "Delivery",
shortName: "DEL",
level: 5,
parentId: null,
sortOrder: 10,
isActive: true,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
},
]);
const treeFindMany = vi.fn().mockResolvedValue([
{
id: "ou_1",
name: "Delivery",
shortName: "DEL",
level: 5,
parentId: null,
sortOrder: 10,
isActive: true,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
},
]);
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
orgUnit: {
findMany: vi.fn()
.mockImplementationOnce(listFindMany)
.mockImplementationOnce(treeFindMany),
},
}, {
granted: [PermissionKey.MANAGE_RESOURCES],
}));
const listResult = await caller.list({ level: 5, isActive: true });
const treeResult = await caller.getTree({ isActive: true });
expect(listResult).toHaveLength(1);
expect(treeResult).toEqual([
expect.objectContaining({
id: "ou_1",
name: "Delivery",
children: [],
}),
]);
expect(listFindMany).toHaveBeenCalledWith({
where: { level: 5, isActive: true },
orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
});
expect(treeFindMany).toHaveBeenCalledWith({
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
});
});