diff --git a/packages/api/src/__tests__/resource-access.test.ts b/packages/api/src/__tests__/resource-access.test.ts new file mode 100644 index 0000000..95e5c22 --- /dev/null +++ b/packages/api/src/__tests__/resource-access.test.ts @@ -0,0 +1,102 @@ +import { TRPCError } from "@trpc/server"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { + assertCanReadResource, + canReadAllResources, + findOwnedResourceId, + resolveResourcePermissions, +} from "../lib/resource-access.js"; + +describe("resource access helpers", () => { + it("returns no permissions without a db user", () => { + expect(resolveResourcePermissions({ dbUser: null, roleDefaults: null })).toEqual(new Set()); + }); + + it("treats managers with resource permissions as staff readers", () => { + const permissions = resolveResourcePermissions({ + dbUser: { + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + } as never, + roleDefaults: null, + }); + + expect(permissions.has(PermissionKey.VIEW_ALL_RESOURCES)).toBe(true); + expect(canReadAllResources({ + dbUser: { + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + } as never, + roleDefaults: null, + })).toBe(true); + }); + + it("returns null when no linked resource lookup is possible", async () => { + await expect(findOwnedResourceId({ + dbUser: { id: "user_1" } as never, + roleDefaults: null, + db: {}, + })).resolves.toBeNull(); + }); + + it("returns the owned resource id when the lookup succeeds", async () => { + const findFirst = vi.fn().mockResolvedValue({ id: "res_1" }); + + await expect(findOwnedResourceId({ + dbUser: { id: "user_1" } as never, + roleDefaults: null, + db: { + resource: { + findFirst, + }, + } as never, + })).resolves.toBe("res_1"); + + expect(findFirst).toHaveBeenCalledWith({ + where: { userId: "user_1" }, + select: { id: true }, + }); + }); + + it("allows staff readers to access arbitrary resources without ownership lookup", async () => { + const findFirst = vi.fn(); + + await expect(assertCanReadResource({ + dbUser: { + id: "mgr_1", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + } as never, + roleDefaults: null, + db: { + resource: { + findFirst, + }, + } as never, + }, "res_1")).resolves.toBeUndefined(); + + expect(findFirst).not.toHaveBeenCalled(); + }); + + it("rejects non-owned resources for regular users", async () => { + const findFirst = vi.fn().mockResolvedValue({ id: "res_own" }); + + await expect(assertCanReadResource({ + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: null, + } as never, + roleDefaults: null, + db: { + resource: { + findFirst, + }, + } as never, + }, "res_other", "custom message")).rejects.toEqual(expect.objectContaining>({ + code: "FORBIDDEN", + message: "custom message", + })); + }); +}); diff --git a/packages/api/src/__tests__/resource-router-auth.test.ts b/packages/api/src/__tests__/resource-router-auth.test.ts index 52009ca..a801526 100644 --- a/packages/api/src/__tests__/resource-router-auth.test.ts +++ b/packages/api/src/__tests__/resource-router-auth.test.ts @@ -136,7 +136,7 @@ describe("resource router authorization", () => { expect(result).toBeNull(); expect(findUnique).toHaveBeenCalledWith({ - where: { email: "user@example.com" }, + where: { id: "user_1" }, select: { resource: { select: { @@ -172,4 +172,149 @@ describe("resource router authorization", () => { expect(getAnonymizationDirectory).toHaveBeenCalledOnce(); expect(anonymizeResource).toHaveBeenCalledWith(resource, null); }); + + it("uses the db user id for self-service resource lookups even when the session email is stale", async () => { + const findUnique = vi.fn().mockResolvedValue({ resource: null }); + const caller = createCaller({ + session: { + user: { email: "stale@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: { + user: { + findUnique, + }, + } as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: null, + }, + roleDefaults: null, + }); + + await caller.getMyResource(); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { + resource: { + select: { + id: true, + displayName: true, + eid: true, + chapter: true, + }, + }, + }, + }); + }); + + it("requires authentication for hover-card lookups", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findUnique, + }, + }, { session: false })); + + await expect(caller.getHoverCard({ id: "res_1" })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("blocks regular users from manager-only skill imports for arbitrary resources", async () => { + const findUnique = vi.fn(); + const update = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findUnique, + update, + }, + })); + + await expect( + caller.importSkillMatrixForResource({ + resourceId: "res_1", + skills: [{ skill: "Houdini", proficiency: 5 }], + }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("requires authentication for manager-only skill imports for arbitrary resources", async () => { + const findUnique = vi.fn(); + const update = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findUnique, + update, + }, + }, { role: SystemRole.MANAGER, session: false })); + + await expect( + caller.importSkillMatrixForResource({ + resourceId: "res_1", + skills: [{ skill: "Houdini", proficiency: 5 }], + }), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("blocks non-admin users from batch skill matrix imports", async () => { + const findMany = vi.fn(); + const transaction = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findMany, + }, + $transaction: transaction, + }, { role: SystemRole.MANAGER })); + + await expect( + caller.batchImportSkillMatrices({ + entries: [{ eid: "E-001", skills: [{ skill: "Maya", proficiency: 4 }] }], + }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + + expect(findMany).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("requires authentication for admin-only batch skill matrix imports", async () => { + const findMany = vi.fn(); + const transaction = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findMany, + }, + $transaction: transaction, + }, { role: SystemRole.ADMIN, session: false })); + + await expect( + caller.batchImportSkillMatrices({ + entries: [{ eid: "E-001", skills: [{ skill: "Maya", proficiency: 4 }] }], + }), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findMany).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); }); diff --git a/packages/api/src/__tests__/resource-router-crud.test.ts b/packages/api/src/__tests__/resource-router-crud.test.ts index 5b65440..8c92409 100644 --- a/packages/api/src/__tests__/resource-router-crud.test.ts +++ b/packages/api/src/__tests__/resource-router-crud.test.ts @@ -16,6 +16,23 @@ vi.mock("../router/blueprint-validation.js", () => ({ assertBlueprintDynamicFields: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../ai-client.js", () => ({ + createAiClient: vi.fn(() => ({ + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [{ message: { content: "Generated summary" } }], + }), + }, + }, + responses: { + create: vi.fn(), + }, + })), + isAiConfigured: vi.fn().mockReturnValue(true), + loggedAiCall: vi.fn(async (_provider, _model, _promptLength, fn) => fn()), +})); + vi.mock("../lib/anonymization.js", () => ({ anonymizeResource: vi.fn((r: Record) => r), anonymizeResources: vi.fn((rs: unknown[]) => rs), @@ -24,7 +41,18 @@ vi.mock("../lib/anonymization.js", () => ({ resolveResourceIdsByDisplayedEids: vi.fn().mockResolvedValue(new Map()), })); +vi.mock("../lib/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + import { resourceRouter } from "../router/resource.js"; +import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js"; +import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { createCallerFactory } from "../trpc.js"; const createCaller = createCallerFactory(resourceRouter); @@ -44,6 +72,21 @@ function createManagerCaller(db: Record) { }); } +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "admin_1", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + }); +} + function createProtectedCaller(db: Record) { return createCaller({ session: { @@ -221,6 +264,65 @@ describe("resource router CRUD", () => { }); }); + describe("getByEid", () => { + it("returns the matching resource for staff callers", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(sampleResource), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.getByEid({ eid: "E-001" }); + + expect(result).toMatchObject({ + id: "res_1", + displayName: "Alice", + eid: "E-001", + }); + expect(db.resource.findUnique).toHaveBeenCalledWith({ where: { eid: "E-001" } }); + }); + + it("throws NOT_FOUND when the requested eid does not exist", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + + const caller = createManagerCaller(db); + await expect(caller.getByEid({ eid: "E-404" })).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + }); + + it("resolves anonymized alias eids through the directory fallback", async () => { + vi.mocked(getAnonymizationDirectory).mockResolvedValueOnce({ + byAliasEid: new Map([["alias-e-001", "res_1"]]), + } as Awaited>); + + const findUnique = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(sampleResource); + const db = { + resource: { + findUnique, + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.getByEid({ eid: "alias-e-001" }); + + expect(result).toMatchObject({ + id: "res_1", + eid: "E-001", + }); + expect(findUnique).toHaveBeenNthCalledWith(1, { where: { eid: "alias-e-001" } }); + expect(findUnique).toHaveBeenNthCalledWith(2, { where: { id: "res_1" } }); + }); + }); + // ─── create ─────────────────────────────────────────────────────────────── describe("create", () => { @@ -361,6 +463,60 @@ describe("resource router CRUD", () => { }); }); + describe("batchDeactivate", () => { + it("deactivates all requested resources and records one audit entry", async () => { + const update = vi + .fn() + .mockResolvedValueOnce({ ...sampleResource, id: "res_1", isActive: false }) + .mockResolvedValueOnce({ ...sampleResource, id: "res_2", isActive: false }); + const db = { + resource: { + update, + }, + $transaction: vi.fn(async (operations: Promise[]) => Promise.all(operations)), + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + + const caller = createManagerCaller(db); + const result = await caller.batchDeactivate({ ids: ["res_1", "res_2"] }); + + expect(result).toEqual({ count: 2 }); + expect(db.$transaction).toHaveBeenCalledTimes(1); + expect(db.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + entityType: "Resource", + action: "UPDATE", + }), + }), + ); + }); + }); + + describe("chapters", () => { + it("returns active chapter names in ascending order", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { chapter: "Animation" }, + { chapter: "CGI" }, + ]), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.chapters(); + + expect(result).toEqual(["Animation", "CGI"]); + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { isActive: true, chapter: { not: null } }, + select: { chapter: true }, + distinct: ["chapter"], + orderBy: { chapter: "asc" }, + }); + }); + }); + // ─── getHoverCard ───────────────────────────────────────────────────────── describe("getHoverCard", () => { @@ -478,6 +634,10 @@ describe("resource router CRUD", () => { skills: [{ skill: "Maya", proficiency: 4 }], }); + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + include: { resource: true }, + }); expect(db.resource.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "res_1" }, @@ -502,6 +662,171 @@ describe("resource router CRUD", () => { skills: [{ skill: "Nuke", proficiency: 3 }], }), ).rejects.toThrow("No resource linked to your account"); + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + include: { resource: true }, + }); + }); + }); + + describe("importSkillMatrixForResource", () => { + it("updates skills for an existing resource", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(sampleResource), + update: vi.fn().mockResolvedValue(sampleResource), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.importSkillMatrixForResource({ + resourceId: "res_1", + skills: [{ skill: "Houdini", proficiency: 5 }], + }); + + expect(result).toEqual({ count: 1 }); + expect(db.resource.findUnique).toHaveBeenCalledWith({ where: { id: "res_1" } }); + expect(db.resource.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "res_1" }, + }), + ); + }); + + it("throws NOT_FOUND when the target resource does not exist", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + update: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + await expect( + caller.importSkillMatrixForResource({ + resourceId: "missing", + skills: [{ skill: "Houdini", proficiency: 5 }], + }), + ).rejects.toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + expect(db.resource.update).not.toHaveBeenCalled(); + }); + }); + + describe("batchImportSkillMatrices", () => { + it("updates all matching resources in one transaction and reports missing eids", async () => { + const update = vi + .fn() + .mockResolvedValueOnce({ ...sampleResource, id: "res_1" }) + .mockResolvedValueOnce({ ...sampleResource, id: "res_2", eid: "E-002" }); + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { id: "res_1", eid: "E-001" }, + { id: "res_2", eid: "E-002" }, + ]), + update, + }, + $transaction: vi.fn(async (operations: Promise[]) => Promise.all(operations)), + }; + + const caller = createAdminCaller(db); + const result = await caller.batchImportSkillMatrices({ + entries: [ + { eid: "E-001", skills: [{ skill: "Houdini", proficiency: 5 }] }, + { eid: "E-002", skills: [{ skill: "Nuke", proficiency: 4 }] }, + { eid: "E-404", skills: [{ skill: "Maya", proficiency: 3 }] }, + ], + }); + + expect(result).toEqual({ updated: 2, notFound: 1 }); + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { eid: { in: ["E-001", "E-002", "E-404"] } }, + select: { id: true, eid: true }, + }); + expect(db.$transaction).toHaveBeenCalledTimes(1); + expect(db.resource.update).toHaveBeenCalledTimes(2); + }); + }); + + describe("generateAiSummary", () => { + it("persists the generated summary for the requested resource", async () => { + const update = vi.fn().mockResolvedValue({ + ...sampleResource, + aiSummary: "Generated summary", + }); + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + ...sampleResource, + skills: [ + { skill: "Maya", proficiency: 5, isMainSkill: true }, + { skill: "Nuke", proficiency: 4 }, + ], + areaRole: { name: "3D Artist" }, + }), + update, + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + id: "singleton", + aiProvider: "openai", + azureOpenAiDeployment: "gpt-test", + aiSummaryPrompt: null, + aiMaxCompletionTokens: 200, + aiTemperature: 0.7, + }), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.generateAiSummary({ resourceId: "res_1" }); + + expect(isAiConfigured).toHaveBeenCalledWith(expect.objectContaining({ + azureOpenAiDeployment: "gpt-test", + })); + expect(createAiClient).toHaveBeenCalledWith(expect.objectContaining({ + azureOpenAiDeployment: "gpt-test", + })); + expect(loggedAiCall).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "res_1" }, + data: expect.objectContaining({ + aiSummary: "Generated summary", + aiSummaryUpdatedAt: expect.any(Date), + }), + }), + ); + expect(result).toEqual({ summary: "Generated summary" }); + }); + + it("fails fast when AI is not configured", async () => { + vi.mocked(isAiConfigured).mockReturnValueOnce(false); + + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + ...sampleResource, + areaRole: { name: "3D Artist" }, + }), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + id: "singleton", + aiProvider: "openai", + azureOpenAiDeployment: null, + }), + }, + }; + + const caller = createManagerCaller(db); + + await expect(caller.generateAiSummary({ resourceId: "res_1" })).rejects.toThrow( + "AI is not configured. Please set credentials in Admin → Settings.", + ); + expect(createAiClient).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/api/src/lib/resource-access.ts b/packages/api/src/lib/resource-access.ts new file mode 100644 index 0000000..65a5aa5 --- /dev/null +++ b/packages/api/src/lib/resource-access.ts @@ -0,0 +1,62 @@ +import { TRPCError } from "@trpc/server"; +import { + PermissionKey, + SystemRole, + resolvePermissions, + type PermissionOverrides, +} from "@capakraken/shared"; +import type { TRPCContext } from "../trpc.js"; + +export type ResourceReadContext = Pick; + +export function resolveResourcePermissions(ctx: Pick): Set { + if (!ctx.dbUser) { + return new Set(); + } + + return resolvePermissions( + ctx.dbUser.systemRole as SystemRole, + ctx.dbUser.permissionOverrides as PermissionOverrides | null, + ctx.roleDefaults ?? undefined, + ); +} + +export function canReadAllResources(ctx: Pick): boolean { + const permissions = resolveResourcePermissions(ctx); + return permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || permissions.has(PermissionKey.MANAGE_RESOURCES); +} + +export async function findOwnedResourceId(ctx: ResourceReadContext): Promise { + if (!ctx.dbUser?.id) { + return null; + } + + if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { + return null; + } + + const resource = await ctx.db.resource.findFirst({ + where: { userId: ctx.dbUser.id }, + select: { id: true }, + }); + + return resource?.id ?? null; +} + +export async function assertCanReadResource( + ctx: ResourceReadContext, + resourceId: string, + message = "You can only view your own resource data", +): Promise { + if (canReadAllResources(ctx)) { + return; + } + + const ownedResourceId = await findOwnedResourceId(ctx); + if (!ownedResourceId || ownedResourceId !== resourceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message, + }); + } +}