test(api): cover shared resource access rules

This commit is contained in:
2026-03-31 22:38:02 +02:00
parent 6d4de85660
commit f3f7bb312b
4 changed files with 635 additions and 1 deletions
@@ -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<Partial<TRPCError>>({
code: "FORBIDDEN",
message: "custom message",
}));
});
});
@@ -136,7 +136,7 @@ describe("resource router authorization", () => {
expect(result).toBeNull(); expect(result).toBeNull();
expect(findUnique).toHaveBeenCalledWith({ expect(findUnique).toHaveBeenCalledWith({
where: { email: "user@example.com" }, where: { id: "user_1" },
select: { select: {
resource: { resource: {
select: { select: {
@@ -172,4 +172,149 @@ describe("resource router authorization", () => {
expect(getAnonymizationDirectory).toHaveBeenCalledOnce(); expect(getAnonymizationDirectory).toHaveBeenCalledOnce();
expect(anonymizeResource).toHaveBeenCalledWith(resource, null); 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();
});
}); });
@@ -16,6 +16,23 @@ vi.mock("../router/blueprint-validation.js", () => ({
assertBlueprintDynamicFields: vi.fn().mockResolvedValue(undefined), 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", () => ({ vi.mock("../lib/anonymization.js", () => ({
anonymizeResource: vi.fn((r: Record<string, unknown>) => r), anonymizeResource: vi.fn((r: Record<string, unknown>) => r),
anonymizeResources: vi.fn((rs: unknown[]) => rs), anonymizeResources: vi.fn((rs: unknown[]) => rs),
@@ -24,7 +41,18 @@ vi.mock("../lib/anonymization.js", () => ({
resolveResourceIdsByDisplayedEids: vi.fn().mockResolvedValue(new Map()), 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 { resourceRouter } from "../router/resource.js";
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
import { getAnonymizationDirectory } from "../lib/anonymization.js";
import { createCallerFactory } from "../trpc.js"; import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(resourceRouter); const createCaller = createCallerFactory(resourceRouter);
@@ -44,6 +72,21 @@ function createManagerCaller(db: Record<string, unknown>) {
}); });
} }
function createAdminCaller(db: Record<string, unknown>) {
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<string, unknown>) { function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({ return createCaller({
session: { 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<ReturnType<typeof getAnonymizationDirectory>>);
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 ─────────────────────────────────────────────────────────────── // ─── create ───────────────────────────────────────────────────────────────
describe("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<unknown>[]) => 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 ───────────────────────────────────────────────────────── // ─── getHoverCard ─────────────────────────────────────────────────────────
describe("getHoverCard", () => { describe("getHoverCard", () => {
@@ -478,6 +634,10 @@ describe("resource router CRUD", () => {
skills: [{ skill: "Maya", proficiency: 4 }], skills: [{ skill: "Maya", proficiency: 4 }],
}); });
expect(db.user.findUnique).toHaveBeenCalledWith({
where: { id: "user_1" },
include: { resource: true },
});
expect(db.resource.update).toHaveBeenCalledWith( expect(db.resource.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { id: "res_1" }, where: { id: "res_1" },
@@ -502,6 +662,171 @@ describe("resource router CRUD", () => {
skills: [{ skill: "Nuke", proficiency: 3 }], skills: [{ skill: "Nuke", proficiency: 3 }],
}), }),
).rejects.toThrow("No resource linked to your account"); ).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<unknown>[]) => 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();
}); });
}); });
}); });
+62
View File
@@ -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<TRPCContext, "db" | "dbUser" | "roleDefaults">;
export function resolveResourcePermissions(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">): Set<PermissionKey> {
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<TRPCContext, "dbUser" | "roleDefaults">): boolean {
const permissions = resolveResourcePermissions(ctx);
return permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || permissions.has(PermissionKey.MANAGE_RESOURCES);
}
export async function findOwnedResourceId(ctx: ResourceReadContext): Promise<string | null> {
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<void> {
if (canReadAllResources(ctx)) {
return;
}
const ownedResourceId = await findOwnedResourceId(ctx);
if (!ownedResourceId || ownedResourceId !== resourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message,
});
}
}