diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 2144981..f17e886 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -20,8 +20,10 @@ - `getMyResource`: `self-service` - `getById`, `getByEid`, `getHoverCard`, `getByIdentifier`, `getByIdentifierDetail`, `resolveByIdentifier`, `getChargeabilitySummary`: `self-service` unless the caller also has `resource-overview` - `directory`: `authenticated-safe-lookup` +- `chapters`: `authenticated-safe-lookup` - `listSummaries`, `listSummariesDetail`, `listStaff`, `resolveResponsiblePersonName`: `resource-overview` - `getSkillsAnalytics`, `searchBySkills`, `listWithUtilization`, `getChargeabilityStats`, `getSkillMarketplace`: `controller-finance` +- `importSkillMatrix`: `self-service` - create, update, deactivate, batch update, imports for other users: `manager-write` or `admin-only` ### `packages/api/src/router/project.ts` diff --git a/packages/api/src/__tests__/resource-router-auth.test.ts b/packages/api/src/__tests__/resource-router-auth.test.ts new file mode 100644 index 0000000..369388c --- /dev/null +++ b/packages/api/src/__tests__/resource-router-auth.test.ts @@ -0,0 +1,96 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { resourceRouter } from "../router/resource.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(resourceRouter); + +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.MANAGER ? "user_mgr" : "user_1", + systemRole: role, + permissionOverrides: null, + } + : null, + }; +} + +describe("resource router authorization", () => { + it("requires authentication for chapter lookups", async () => { + const findMany = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findMany, + }, + }, { session: false })); + + await expect(caller.chapters()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findMany).not.toHaveBeenCalled(); + }); + + it("keeps chapter lookups available to authenticated users as safe lookup data", async () => { + const findMany = vi.fn().mockResolvedValue([ + { chapter: "Art Direction" }, + { chapter: "Project Management" }, + ]); + const caller = createCaller(createContext({ + resource: { + findMany, + }, + })); + + const result = await caller.chapters(); + + expect(result).toEqual(["Art Direction", "Project Management"]); + expect(findMany).toHaveBeenCalledWith({ + where: { isActive: true, chapter: { not: null } }, + select: { chapter: true }, + distinct: ["chapter"], + orderBy: { chapter: "asc" }, + }); + }); + + it("requires authentication for self-service skill matrix imports", async () => { + const findUnique = vi.fn(); + const update = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + }, + resource: { + update, + }, + }, { session: false })); + + await expect(caller.importSkillMatrix({ + skills: [{ skill: "Maya", proficiency: 4 }], + })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); +});