test(api): cover shared resource access rules
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user