c0c5f762b8
batchUpdateCustomFields used $executeRaw to merge a manager-supplied
record straight into Resource.dynamicFields with no key whitelist —
so a manager could pollute the JSONB namespace with arbitrary keys
(e.g. ones admin tools later interpret). Separately, several user-facing
JSONB fields (allocation/demand metadata, dynamicFields) were typed as
unbounded z.record(z.string(), z.unknown()), letting clients ship
multi-MB payloads that flow into DB writes, audit logs, and SSE frames.
- Add BoundedJsonRecord helper (shared) — 64 keys / depth 4 /
8 KB strings / 32 KB serialized total. Conservative defaults; call
sites needing more should use a strict object schema.
- Apply BoundedJsonRecord to the highest-traffic untrusted JSONB inputs:
allocation metadata (Create/CreateDemandRequirement/CreateAssignment),
resource & project dynamicFields, and the createDemand router input.
- batchUpdateCustomFields:
* Tighten input schema (key length, value bounds, max 100 keys).
* Fetch each target resource and verify all input keys are in the
union of (specific blueprint defs) ∪ (active global RESOURCE
blueprint defs) for that resource. Empty whitelist → reject all
keys (stricter than create/update, but appropriate for a bulk
escape-hatch endpoint).
* Run the existing per-key value validator afterwards.
* 404 if any requested id does not exist (was silently skipped).
- New helper getAllowedDynamicFieldKeys() in blueprint-validation.
- 7 new BoundedJsonRecord tests, 2 new batchUpdateCustomFields tests
covering the whitelist-rejection and not-found paths.
Covers EAPPS 3.2.7 (input bounds) / OWASP A03 (injection / mass assignment).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { resourceRouter } from "../router/resource.js";
|
|
import { createCallerFactory } from "../trpc.js";
|
|
|
|
vi.mock("../lib/logger.js", () => ({
|
|
logger: {
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const createCaller = createCallerFactory(resourceRouter);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function createManagerCaller(db: Record<string, unknown>) {
|
|
return createCaller({
|
|
session: {
|
|
user: { email: "mgr@example.com", name: "Manager", image: null },
|
|
expires: "2099-01-01T00:00:00.000Z",
|
|
},
|
|
db: db as never,
|
|
dbUser: {
|
|
id: "user_mgr",
|
|
systemRole: SystemRole.MANAGER,
|
|
permissionOverrides: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
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: "user_admin",
|
|
systemRole: SystemRole.ADMIN,
|
|
permissionOverrides: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
function createUserCaller(db: Record<string, unknown>) {
|
|
return createCaller({
|
|
session: {
|
|
user: { email: "user@example.com", name: "User", image: null },
|
|
expires: "2099-01-01T00:00:00.000Z",
|
|
},
|
|
db: db as never,
|
|
dbUser: {
|
|
id: "user_1",
|
|
systemRole: SystemRole.USER,
|
|
permissionOverrides: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
const VALID_CREATE_INPUT = {
|
|
eid: "EMP-001",
|
|
displayName: "Jane Doe",
|
|
email: "jane@example.com",
|
|
chapter: "Engineering",
|
|
lcrCents: 5000,
|
|
ucrCents: 8000,
|
|
currency: "EUR",
|
|
chargeabilityTarget: 80,
|
|
availability: {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
},
|
|
skills: [],
|
|
dynamicFields: {},
|
|
};
|
|
|
|
const MOCK_CREATED_RESOURCE = {
|
|
id: "res_new",
|
|
...VALID_CREATE_INPUT,
|
|
resourceRoles: [],
|
|
};
|
|
|
|
function mockDb(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
update: vi.fn().mockResolvedValue({ id: "res_1", isActive: false }),
|
|
delete: vi.fn().mockResolvedValue({}),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
blueprint: {
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
auditLog: {
|
|
create: vi.fn().mockResolvedValue({}),
|
|
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
assignment: {
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
vacation: {
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
resourceRole: {
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => {
|
|
const tx = {
|
|
resource: {
|
|
create: vi.fn().mockResolvedValue(MOCK_CREATED_RESOURCE),
|
|
update: vi
|
|
.fn()
|
|
.mockResolvedValue({ id: "res_1", ...VALID_CREATE_INPUT, resourceRoles: [] }),
|
|
delete: vi.fn().mockResolvedValue({}),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
auditLog: {
|
|
create: vi.fn().mockResolvedValue({}),
|
|
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
resourceRole: {
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
assignment: {
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
vacation: {
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
$executeRaw: vi.fn().mockResolvedValue(1),
|
|
};
|
|
return fn(tx);
|
|
}),
|
|
$executeRaw: vi.fn().mockResolvedValue(1),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("resource create", () => {
|
|
it("rejects non-manager users", async () => {
|
|
const caller = createUserCaller(mockDb());
|
|
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
});
|
|
});
|
|
|
|
it("rejects duplicate EID or email", async () => {
|
|
const db = mockDb({
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "existing", eid: "EMP-001" }),
|
|
},
|
|
});
|
|
const caller = createManagerCaller(db);
|
|
|
|
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
|
|
code: "CONFLICT",
|
|
});
|
|
});
|
|
|
|
it("rejects more than one primary role", async () => {
|
|
const caller = createManagerCaller(mockDb());
|
|
|
|
await expect(
|
|
caller.create({
|
|
...VALID_CREATE_INPUT,
|
|
roles: [
|
|
{ roleId: "role_1", isPrimary: true },
|
|
{ roleId: "role_2", isPrimary: true },
|
|
],
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
message: expect.stringContaining("primary role"),
|
|
});
|
|
});
|
|
|
|
it("creates resource with audit log for managers", async () => {
|
|
const db = mockDb();
|
|
const caller = createManagerCaller(db);
|
|
|
|
const result = await caller.create(VALID_CREATE_INPUT);
|
|
expect(result).toMatchObject({ id: "res_new" });
|
|
expect(db.$transaction).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("resource update", () => {
|
|
it("rejects non-manager users", async () => {
|
|
const caller = createUserCaller(mockDb());
|
|
await expect(
|
|
caller.update({ id: "res_1", data: { displayName: "Updated" } }),
|
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
});
|
|
|
|
it("throws NOT_FOUND for non-existent resource", async () => {
|
|
const db = mockDb({
|
|
resource: {
|
|
...mockDb().resource,
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
},
|
|
});
|
|
const caller = createManagerCaller(db);
|
|
|
|
await expect(
|
|
caller.update({ id: "res_missing", data: { displayName: "Updated" } }),
|
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
});
|
|
|
|
it("rejects multiple primary roles on update", async () => {
|
|
const db = mockDb({
|
|
resource: {
|
|
...mockDb().resource,
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: "res_1",
|
|
...VALID_CREATE_INPUT,
|
|
blueprintId: null,
|
|
dynamicFields: {},
|
|
}),
|
|
},
|
|
});
|
|
const caller = createManagerCaller(db);
|
|
|
|
await expect(
|
|
caller.update({
|
|
id: "res_1",
|
|
data: {
|
|
roles: [
|
|
{ roleId: "role_1", isPrimary: true },
|
|
{ roleId: "role_2", isPrimary: true },
|
|
],
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: "BAD_REQUEST",
|
|
message: expect.stringContaining("primary role"),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resource deactivate", () => {
|
|
it("rejects non-manager users", async () => {
|
|
const caller = createUserCaller(mockDb());
|
|
await expect(caller.deactivate({ id: "res_1" })).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
});
|
|
});
|
|
|
|
it("soft-deletes resource for managers", async () => {
|
|
const db = mockDb();
|
|
const caller = createManagerCaller(db);
|
|
const result = await caller.deactivate({ id: "res_1" });
|
|
expect(result).toBeDefined();
|
|
expect(db.$transaction).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("resource batchUpdateCustomFields", () => {
|
|
it("rejects non-manager users", async () => {
|
|
const caller = createUserCaller(mockDb());
|
|
await expect(
|
|
caller.batchUpdateCustomFields({
|
|
ids: ["res_1"],
|
|
fields: { department: "Engineering" },
|
|
}),
|
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
});
|
|
|
|
it("validates field types (rejects invalid values)", async () => {
|
|
const caller = createManagerCaller(mockDb());
|
|
|
|
// The hardened schema only accepts string | number | boolean | null
|
|
await expect(
|
|
caller.batchUpdateCustomFields({
|
|
ids: ["res_1"],
|
|
// @ts-expect-error — intentionally passing an array to test schema validation
|
|
fields: { department: ["nested", "array"] },
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("executes batch update with audit log", async () => {
|
|
const db = mockDb({
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{ id: "res_1", blueprintId: null },
|
|
{ id: "res_2", blueprintId: null },
|
|
]),
|
|
update: vi.fn().mockResolvedValue({ id: "res_1", isActive: false }),
|
|
delete: vi.fn().mockResolvedValue({}),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
blueprint: {
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
findMany: vi.fn().mockResolvedValue([
|
|
{
|
|
fieldDefs: [
|
|
{ key: "department", label: "Department", type: "text" },
|
|
{ key: "level", label: "Level", type: "number" },
|
|
],
|
|
},
|
|
]),
|
|
},
|
|
});
|
|
const caller = createManagerCaller(db);
|
|
|
|
const result = await caller.batchUpdateCustomFields({
|
|
ids: ["res_1", "res_2"],
|
|
fields: { department: "Engineering", level: 3 },
|
|
});
|
|
|
|
expect(result).toEqual({ updated: 2 });
|
|
expect(db.$transaction).toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects unknown keys when a blueprint defines the whitelist", async () => {
|
|
const db = mockDb({
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
findMany: vi.fn().mockResolvedValue([{ id: "res_1", blueprintId: "bp_1" }]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
delete: vi.fn().mockResolvedValue({}),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
blueprint: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
target: "RESOURCE",
|
|
fieldDefs: [{ key: "department", label: "Department", type: "text" }],
|
|
}),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
});
|
|
const caller = createManagerCaller(db);
|
|
|
|
await expect(
|
|
caller.batchUpdateCustomFields({
|
|
ids: ["res_1"],
|
|
// "injected" is not in the blueprint's whitelist
|
|
fields: { department: "Engineering", injected: "malicious" },
|
|
}),
|
|
).rejects.toThrow();
|
|
expect(db.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("404s if any requested id does not exist", async () => {
|
|
const db = mockDb({
|
|
resource: {
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
findMany: vi.fn().mockResolvedValue([{ id: "res_1", blueprintId: null }]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
delete: vi.fn().mockResolvedValue({}),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
});
|
|
const caller = createManagerCaller(db);
|
|
|
|
await expect(
|
|
caller.batchUpdateCustomFields({
|
|
ids: ["res_1", "res_missing"],
|
|
fields: { department: "Engineering" },
|
|
}),
|
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
});
|
|
});
|
|
|
|
describe("resource hardDelete", () => {
|
|
it("rejects non-admin users", async () => {
|
|
const caller = createManagerCaller(mockDb());
|
|
await expect(caller.hardDelete({ id: "res_1" })).rejects.toMatchObject({
|
|
code: "FORBIDDEN",
|
|
});
|
|
});
|
|
|
|
it("throws NOT_FOUND for missing resource", async () => {
|
|
const db = mockDb({
|
|
resource: {
|
|
...mockDb().resource,
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
},
|
|
});
|
|
const caller = createAdminCaller(db);
|
|
|
|
await expect(caller.hardDelete({ id: "res_missing" })).rejects.toMatchObject({
|
|
code: "NOT_FOUND",
|
|
});
|
|
});
|
|
|
|
it("deletes resource and cascades for admin", async () => {
|
|
const db = mockDb({
|
|
resource: {
|
|
...mockDb().resource,
|
|
findUnique: vi.fn().mockResolvedValue({ id: "res_1", displayName: "Jane", eid: "EMP-001" }),
|
|
},
|
|
});
|
|
const caller = createAdminCaller(db);
|
|
|
|
const result = await caller.hardDelete({ id: "res_1" });
|
|
expect(result).toEqual({ deleted: true });
|
|
expect(db.$transaction).toHaveBeenCalled();
|
|
});
|
|
});
|