test(api): fill router auth and security coverage gaps
Four new test files — 27 tests total: - role-router-auth.test.ts (8): UNAUTHORIZED/FORBIDDEN on all mutations for unauthenticated/USER callers; MANAGER and ADMIN happy paths - webhook-router-auth.test.ts (6): adminProcedure guard verified for all six webhook procedures across USER/MANAGER/ADMIN roles - comment-sanitization-router.test.ts (4): proves stripHtml runs before db.comment.create — script tags stripped, plain text and @mentions preserved - auth-anomaly-check/route.test.ts (+5 unit tests): detectAuthAnomalies() unit coverage — empty window, global threshold, per-entity threshold, null entityId, and both anomaly types firing simultaneously Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { roleRouter } from "../router/role.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
const createCaller = createCallerFactory(roleRouter);
|
||||
|
||||
function createContext(
|
||||
db: Record<string, unknown>,
|
||||
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.ADMIN ? "user_admin" : "user_1",
|
||||
systemRole: role,
|
||||
permissionOverrides: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("role router authorization", () => {
|
||||
describe("unauthenticated access", () => {
|
||||
it("rejects unauthenticated list call with UNAUTHORIZED", async () => {
|
||||
const roleFindMany = vi.fn();
|
||||
const caller = createCaller(
|
||||
createContext({ role: { findMany: roleFindMany } }, { session: false }),
|
||||
);
|
||||
|
||||
await expect(caller.list({})).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
|
||||
expect(roleFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unauthenticated create call with UNAUTHORIZED", async () => {
|
||||
const roleCreate = vi.fn();
|
||||
const caller = createCaller(
|
||||
createContext({ role: { create: roleCreate } }, { session: false }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
caller.create({ name: "Art Director" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
|
||||
expect(roleCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("USER role — insufficient permissions for mutations", () => {
|
||||
it("forbids USER from calling create", async () => {
|
||||
const roleCreate = vi.fn();
|
||||
const caller = createCaller(
|
||||
createContext({ role: { create: roleCreate } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
caller.create({ name: "Art Director" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Manager or Admin role required",
|
||||
});
|
||||
|
||||
expect(roleCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids USER from calling update", async () => {
|
||||
const roleUpdate = vi.fn();
|
||||
const caller = createCaller(
|
||||
createContext({ role: { update: roleUpdate } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
caller.update({ id: "role_1", data: { name: "Updated Role" } }),
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Manager or Admin role required",
|
||||
});
|
||||
|
||||
expect(roleUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids USER from calling delete", async () => {
|
||||
const roleDelete = vi.fn();
|
||||
const caller = createCaller(
|
||||
createContext({ role: { delete: roleDelete } }),
|
||||
);
|
||||
|
||||
await expect(caller.delete({ id: "role_1" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Manager or Admin role required",
|
||||
});
|
||||
|
||||
expect(roleDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids USER from calling deactivate", async () => {
|
||||
const roleUpdate = vi.fn();
|
||||
const caller = createCaller(
|
||||
createContext({ role: { update: roleUpdate } }),
|
||||
);
|
||||
|
||||
await expect(caller.deactivate({ id: "role_1" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Manager or Admin role required",
|
||||
});
|
||||
|
||||
expect(roleUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MANAGER role — permitted for mutations", () => {
|
||||
it("allows MANAGER to call create without auth error", async () => {
|
||||
const createdRole = {
|
||||
id: "role_new",
|
||||
name: "Art Director",
|
||||
description: null,
|
||||
color: null,
|
||||
isActive: true,
|
||||
_count: { resourceRoles: 0 },
|
||||
};
|
||||
const roleCreate = vi.fn().mockResolvedValue(createdRole);
|
||||
const roleFindUnique = vi.fn().mockResolvedValue(null); // name not taken
|
||||
const auditLogCreate = vi.fn().mockResolvedValue({});
|
||||
// planningEntry count queries (attachZeroAllocationCount path)
|
||||
const planningEntryFindMany = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
role: { create: roleCreate, findUnique: roleFindUnique },
|
||||
auditLog: { create: auditLogCreate },
|
||||
planningEntry: { findMany: planningEntryFindMany },
|
||||
},
|
||||
{ role: SystemRole.MANAGER },
|
||||
),
|
||||
);
|
||||
|
||||
// Should not throw UNAUTHORIZED or FORBIDDEN
|
||||
const result = await caller.create({ name: "Art Director" });
|
||||
|
||||
expect(result).toMatchObject({ id: "role_new", name: "Art Director" });
|
||||
expect(roleCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADMIN role — permitted for mutations", () => {
|
||||
it("allows ADMIN to call delete without auth error", async () => {
|
||||
const existingRole = {
|
||||
id: "role_1",
|
||||
name: "Stale Role",
|
||||
description: null,
|
||||
color: null,
|
||||
isActive: true,
|
||||
_count: { resourceRoles: 0 },
|
||||
};
|
||||
const roleFindUnique = vi.fn().mockResolvedValue(existingRole);
|
||||
const roleDelete = vi.fn().mockResolvedValue(existingRole);
|
||||
const auditLogCreate = vi.fn().mockResolvedValue({});
|
||||
// attachSingleRolePlanningEntryCount calls countPlanningEntries
|
||||
// which needs both demandRequirement and assignment findMany
|
||||
const demandRequirementFindMany = vi.fn().mockResolvedValue([]);
|
||||
const assignmentFindMany = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
role: {
|
||||
findUnique: roleFindUnique,
|
||||
delete: roleDelete,
|
||||
},
|
||||
auditLog: { create: auditLogCreate },
|
||||
demandRequirement: { findMany: demandRequirementFindMany },
|
||||
assignment: { findMany: assignmentFindMany },
|
||||
},
|
||||
{ role: SystemRole.ADMIN },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await caller.delete({ id: "role_1" });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(roleDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user