From c7434c968e7068eb022a7e0c0d4d7b862b91918f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 12:07:26 +0200 Subject: [PATCH] fix(vacation): scope preview requests to owned resources --- docs/route-access-matrix.md | 13 ++ .../__tests__/vacation-router-auth.test.ts | 164 ++++++++++++++++++ .../api/src/__tests__/vacation-router.test.ts | 1 + packages/api/src/router/vacation.ts | 2 + 4 files changed, 180 insertions(+) create mode 100644 packages/api/src/__tests__/vacation-router-auth.test.ts diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index e60122e..15a7756 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -179,6 +179,19 @@ Reasoning: - 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/vacation.ts` + +- `previewRequest`, `list`, `getById`, `getForResource`, `getTeamOverlap`, `getTeamOverlapDetail`, `cancel`: `self-service` for the caller's own resource, with elevated cross-resource reads for manager and admin roles +- `create`: `self-service` for the caller's own resource, with elevated creation for manager and admin roles +- `approve`, `reject`, `getPendingApprovals`, `updateStatus` approval paths: `manager-write` +- `batchCreatePublicHolidays`: `admin-only` + +Reasoning: + +- the employee-facing vacation flows are valid self-service features, but they must not reveal holiday context, overlap data, or request details for arbitrary resources +- manager and admin roles already handle approval and operational cross-resource workflows, so they retain broader access where the route logic explicitly allows it +- bulk public-holiday generation changes organization-wide absence data and therefore belongs 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__/vacation-router-auth.test.ts b/packages/api/src/__tests__/vacation-router-auth.test.ts new file mode 100644 index 0000000..1f9fefe --- /dev/null +++ b/packages/api/src/__tests__/vacation-router-auth.test.ts @@ -0,0 +1,164 @@ +import { SystemRole } from "@capakraken/shared"; +import { VacationType } from "@capakraken/db"; +import { describe, expect, it, vi } from "vitest"; +import { vacationRouter } from "../router/vacation.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(vacationRouter); + +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" : "user_1", + systemRole: role, + permissionOverrides: null, + } + : null, + }; +} + +function previewInput(resourceId: string) { + return { + resourceId, + type: VacationType.ANNUAL, + startDate: new Date("2026-08-10T00:00:00.000Z"), + endDate: new Date("2026-08-12T00:00:00.000Z"), + }; +} + +describe("vacation router authorization", () => { + it("requires authentication for preview requests", async () => { + const resourceFindFirst = vi.fn(); + const resourceFindUnique = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findFirst: resourceFindFirst, + findUnique: resourceFindUnique, + }, + vacation: { + findMany: vi.fn(), + }, + }, { session: false })); + + await expect(caller.previewRequest(previewInput("res_1"))).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(resourceFindFirst).not.toHaveBeenCalled(); + expect(resourceFindUnique).not.toHaveBeenCalled(); + }); + + it("forbids regular users from previewing another resource's request", async () => { + const resourceFindUnique = vi.fn(); + const vacationFindMany = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_own" }), + findUnique: resourceFindUnique, + }, + vacation: { + findMany: vacationFindMany, + }, + })); + + await expect(caller.previewRequest(previewInput("res_other"))).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only view vacation data for your own resource", + }); + + expect(resourceFindUnique).not.toHaveBeenCalled(); + expect(vacationFindMany).not.toHaveBeenCalled(); + }); + + it("allows regular users to preview their own requests", async () => { + const resourceFindUnique = vi.fn().mockResolvedValue({ + federalState: "BY", + countryId: "country_de", + metroCityId: null, + country: { code: "DE", name: "Germany" }, + metroCity: null, + }); + const vacationFindMany = vi.fn().mockResolvedValue([]); + const caller = createCaller(createContext({ + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_own" }), + findUnique: resourceFindUnique, + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + vacation: { + findMany: vacationFindMany, + }, + })); + + const result = await caller.previewRequest(previewInput("res_own")); + + expect(result.requestedDays).toBe(3); + expect(resourceFindUnique).toHaveBeenCalledWith({ + where: { id: "res_own" }, + select: { + federalState: true, + countryId: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, + }); + }); + + it("allows managers to preview requests for other resources", async () => { + const resourceFindFirst = vi.fn(); + const resourceFindUnique = vi.fn().mockResolvedValue({ + federalState: "BY", + countryId: "country_de", + metroCityId: null, + country: { code: "DE", name: "Germany" }, + metroCity: null, + }); + const caller = createCaller(createContext({ + resource: { + findFirst: resourceFindFirst, + findUnique: resourceFindUnique, + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, { role: SystemRole.MANAGER })); + + const result = await caller.previewRequest(previewInput("res_other")); + + expect(result.requestedDays).toBe(3); + expect(resourceFindFirst).not.toHaveBeenCalled(); + expect(resourceFindUnique).toHaveBeenCalledWith({ + where: { id: "res_other" }, + select: { + federalState: true, + countryId: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, + }); + }); +}); diff --git a/packages/api/src/__tests__/vacation-router.test.ts b/packages/api/src/__tests__/vacation-router.test.ts index 4341037..933dc76 100644 --- a/packages/api/src/__tests__/vacation-router.test.ts +++ b/packages/api/src/__tests__/vacation-router.test.ts @@ -128,6 +128,7 @@ function createVacationDb(overrides: Record = {}) { findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]), }, resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), findUnique: vi.fn().mockImplementation(async (args?: { select?: Record }) => { const select = args?.select ?? {}; return { diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index f7f00c0..77a5949 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -266,6 +266,8 @@ export const vacationRouter = createTRPCRouter({ previewRequest: protectedProcedure .input(PreviewVacationRequestSchema) .query(async ({ ctx, input }) => { + await assertCanReadVacationResource(ctx, input.resourceId); + const holidayContext = await loadResourceHolidayContext( ctx.db, input.resourceId,