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:
2026-04-02 23:31:26 +02:00
parent 1d02afddfd
commit ed4d4e4640
4 changed files with 616 additions and 0 deletions
@@ -0,0 +1,145 @@
import { SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { commentRouter } from "../router/comment.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../lib/comment-entity-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/comment-entity-registry.js")>();
return {
...actual,
assertCommentEntityAccess: vi.fn().mockResolvedValue({
listMentionCandidates: vi.fn(),
buildLink: vi.fn().mockReturnValue("/estimates/est_1"),
}),
};
});
vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/create-notification.js", () => ({
createNotification: vi.fn().mockResolvedValue(undefined),
}));
const createCaller = createCallerFactory(commentRouter);
function createContext(db: Record<string, unknown>, role = SystemRole.MANAGER) {
return {
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: role,
permissionOverrides: null,
},
};
}
function makeDb(commentCreate: ReturnType<typeof vi.fn>) {
return {
estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
comment: {
create: commentCreate,
},
notification: {
create: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
};
}
describe("comment router — HTML sanitization before DB write", () => {
it("strips script tags from comment body before writing to the database", async () => {
// stripHtml removes the <script> and </script> tags; the inner text "alert(1)"
// is plain text and is therefore preserved — the HTML injection vector (the tags
// themselves) is eliminated.
const sanitizedBody = "alert(1)Hello";
const commentCreate = vi.fn().mockResolvedValue({
id: "comment_1",
body: sanitizedBody,
author: { id: "user_mgr", name: "Manager", email: "mgr@example.com", image: null },
});
const caller = createCaller(createContext(makeDb(commentCreate)));
await caller.create({
entityType: "estimate",
entityId: "est_1",
body: "<script>alert(1)</script>Hello",
});
expect(commentCreate).toHaveBeenCalledOnce();
const callArg = commentCreate.mock.calls[0]![0] as { data: { body: string } };
// Tags are stripped — no angle brackets remain in what the DB receives
expect(callArg.data.body).toBe(sanitizedBody);
expect(callArg.data.body).not.toContain("<script>");
expect(callArg.data.body).not.toContain("</script>");
});
it("strips bold and italic tags but preserves the text content", async () => {
const commentCreate = vi.fn().mockResolvedValue({
id: "comment_2",
body: "This is bold and italic",
author: { id: "user_mgr", name: "Manager", email: "mgr@example.com", image: null },
});
const caller = createCaller(createContext(makeDb(commentCreate)));
await caller.create({
entityType: "estimate",
entityId: "est_1",
body: "This is <b>bold</b> and <i>italic</i>",
});
expect(commentCreate).toHaveBeenCalledOnce();
const callArg = commentCreate.mock.calls[0]![0] as { data: { body: string } };
expect(callArg.data.body).toBe("This is bold and italic");
});
it("passes plain text through unchanged", async () => {
const commentCreate = vi.fn().mockResolvedValue({
id: "comment_3",
body: "Just a plain comment",
author: { id: "user_mgr", name: "Manager", email: "mgr@example.com", image: null },
});
const caller = createCaller(createContext(makeDb(commentCreate)));
await caller.create({
entityType: "estimate",
entityId: "est_1",
body: "Just a plain comment",
});
expect(commentCreate).toHaveBeenCalledOnce();
const callArg = commentCreate.mock.calls[0]![0] as { data: { body: string } };
expect(callArg.data.body).toBe("Just a plain comment");
});
it("strips HTML but preserves mention syntax and correctly populates the mentions array", async () => {
const commentCreate = vi.fn().mockResolvedValue({
id: "comment_4",
body: "Hi @[Alice](user_1) — please review",
author: { id: "user_mgr", name: "Manager", email: "mgr@example.com", image: null },
});
const caller = createCaller(createContext(makeDb(commentCreate)));
await caller.create({
entityType: "estimate",
entityId: "est_1",
body: "Hi @[Alice](user_1) — <b>please review</b>",
});
expect(commentCreate).toHaveBeenCalledOnce();
const callArg = commentCreate.mock.calls[0]![0] as {
data: { body: string; mentions: string[] };
};
expect(callArg.data.body).toBe("Hi @[Alice](user_1) — please review");
expect(callArg.data.mentions).toContain("user_1");
});
});