From 22cff9648efc5e0b6d1d481833e79aeb5bc6d2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 12:01:34 +0200 Subject: [PATCH] test(entitlement): cover self-service and role boundaries --- docs/route-access-matrix.md | 12 ++ .../__tests__/entitlement-router-auth.test.ts | 185 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 packages/api/src/__tests__/entitlement-router-auth.test.ts diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 81bdce9..e60122e 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -167,6 +167,18 @@ Reasoning: - the calendar catalog is currently consumed in the web app only by the admin vacation editor, so broad authenticated reads expose internal configuration without a product need - narrowing just the catalog reads keeps the hardening slice small while avoiding regressions in shared holiday-resolution helpers used by vacation, timeline, and assistant flows +### `packages/api/src/router/entitlement.ts` + +- `getBalance`, `getBalanceDetail`: `self-service` for the caller's own resource, with elevated cross-resource reads for controller, manager, and admin roles +- `get`, `set`, `getYearSummary`, `getYearSummaryDetail`: `manager-write` +- `bulkSet`: `admin-only` + +Reasoning: + +- regular users can inspect only their own holiday-aware balance, and the route enforces that by checking resource ownership before loading entitlement data +- cross-resource balance reads and year summaries are operational planning and approval workflows, so they stay with controller/manager/admin audiences rather than broad authenticated access +- bulk entitlement changes affect many users at once and should remain restricted to the smallest administrative audience + ### `packages/api/src/router/notification.ts` - `list`, `unreadCount`, `markRead`, task detail/status routes, reminder routes, and `delete`: `self-service` diff --git a/packages/api/src/__tests__/entitlement-router-auth.test.ts b/packages/api/src/__tests__/entitlement-router-auth.test.ts new file mode 100644 index 0000000..463d91e --- /dev/null +++ b/packages/api/src/__tests__/entitlement-router-auth.test.ts @@ -0,0 +1,185 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { entitlementRouter } from "../router/entitlement.js"; +import { createCallerFactory } from "../trpc.js"; + +vi.mock("@capakraken/db", () => ({ + VacationType: { ANNUAL: "ANNUAL", SICK: "SICK", OTHER: "OTHER", PUBLIC_HOLIDAY: "PUBLIC_HOLIDAY" }, + VacationStatus: { APPROVED: "APPROVED", PENDING: "PENDING", REJECTED: "REJECTED" }, +})); + +const createCaller = createCallerFactory(entitlementRouter); + +function createContext( + db: Record, + options: { + role?: SystemRole; + session?: boolean; + } = {}, +) { + const { role = SystemRole.USER, session = true } = options; + + return { + session: session + ? { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + } + : null, + db: db as never, + dbUser: session + ? { + id: role === SystemRole.ADMIN ? "user_admin" : role === SystemRole.MANAGER ? "user_mgr" : role === SystemRole.CONTROLLER ? "user_ctrl" : "user_1", + systemRole: role, + permissionOverrides: null, + } + : null, + }; +} + +function sampleEntitlement(overrides: Record = {}) { + return { + id: "ent_1", + resourceId: "res_2", + year: 2026, + entitledDays: 30, + carryoverDays: 2, + usedDays: 5, + pendingDays: 3, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + ...overrides, + }; +} + +describe("entitlement router authorization", () => { + it("requires authentication for vacation balance reads", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findUnique, + }, + }, { session: false })); + + await expect(caller.getBalance({ resourceId: "res_1", year: 2026 })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("forbids regular users from reading another resource's balance", async () => { + const systemSettingsFindUnique = vi.fn(); + const vacationEntitlementFindUnique = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findUnique: vi.fn().mockResolvedValue({ userId: "user_2" }), + }, + systemSettings: { + findUnique: systemSettingsFindUnique, + }, + vacationEntitlement: { + findUnique: vacationEntitlementFindUnique, + }, + })); + + await expect(caller.getBalance({ resourceId: "res_2", year: 2026 })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only view your own vacation balance", + }); + + expect(systemSettingsFindUnique).not.toHaveBeenCalled(); + expect(vacationEntitlementFindUnique).not.toHaveBeenCalled(); + }); + + it("allows controllers to read broader balance detail without self-service ownership", async () => { + const entitlement = sampleEntitlement({ pendingDays: 0 }); + const resourceFindUnique = vi.fn().mockImplementation(async ({ select }: { select?: Record } = {}) => { + if (select?.userId) { + throw new Error("ownership check should not run for controller reads"); + } + if (select?.displayName || select?.eid) { + return { + ...(select?.displayName ? { displayName: "Alice Example" } : {}), + ...(select?.eid ? { eid: "EMP-002" } : {}), + }; + } + return { + federalState: "BY", + country: { code: "DE" }, + metroCity: null, + }; + }); + const caller = createCaller(createContext({ + resource: { + findUnique: resourceFindUnique, + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }), + }, + vacationEntitlement: { + findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => ( + where.resourceId_year.year === 2026 ? entitlement : null + )), + update: vi.fn().mockResolvedValue(entitlement), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, { role: SystemRole.CONTROLLER })); + + const result = await caller.getBalanceDetail({ resourceId: "res_2", year: 2026 }); + + expect(result).toEqual({ + resource: "Alice Example", + eid: "EMP-002", + year: 2026, + entitlement: 30, + carryOver: 2, + taken: 5, + pending: 0, + remaining: 25, + sickDays: 0, + }); + expect(resourceFindUnique).toHaveBeenCalledWith({ + where: { id: "res_2" }, + select: { displayName: true, eid: true }, + }); + }); + + it("forbids regular users from reading entitlement year summaries", async () => { + const findMany = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findMany, + }, + })); + + await expect(caller.getYearSummary({ year: 2026 })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Manager or Admin role required", + }); + + expect(findMany).not.toHaveBeenCalled(); + }); + + it("forbids managers from bulk-setting entitlements", async () => { + const findMany = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findMany, + }, + vacationEntitlement: { + upsert: vi.fn(), + }, + }, { role: SystemRole.MANAGER })); + + await expect(caller.bulkSet({ year: 2026, entitledDays: 30 })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(findMany).not.toHaveBeenCalled(); + }); +});