/** * Unit tests for mTLS RBAC functions in src/lib/caddy-mtls.ts * * Covers: * - resolveAllowedFingerprints: union of role + cert fingerprints * - buildFingerprintCelExpression: CEL expression generation * - buildMtlsRbacSubroutes: full subroute generation with path rules * - normalizeFingerprint: colon stripping + lowercase */ import { describe, it, expect } from "vitest"; import { resolveAllowedFingerprints, buildFingerprintCelExpression, buildMtlsRbacSubroutes, normalizeFingerprint, type MtlsAccessRuleLike, } from "../../src/lib/caddy-mtls"; // ── Helpers ────────────────────────────────────────────────────────── function makeRule(overrides: Partial = {}): MtlsAccessRuleLike { return { pathPattern: "/admin/*", allowedRoleIds: [], allowedCertIds: [], denyAll: false, ...overrides, }; } // ── normalizeFingerprint ───────────────────────────────────────────── describe("normalizeFingerprint", () => { it("strips colons and lowercases", () => { expect(normalizeFingerprint("AB:CD:EF:12")).toBe("abcdef12"); }); it("handles already-normalized input", () => { expect(normalizeFingerprint("abcdef12")).toBe("abcdef12"); }); it("handles empty string", () => { expect(normalizeFingerprint("")).toBe(""); }); }); // ── resolveAllowedFingerprints ─────────────────────────────────────── describe("resolveAllowedFingerprints", () => { it("resolves fingerprints from roles", () => { const roleFpMap = new Map>([ [1, new Set(["fp_a", "fp_b"])], [2, new Set(["fp_c"])], ]); const certFpMap = new Map(); const rule = makeRule({ allowedRoleIds: [1, 2] }); const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap); expect(result).toEqual(new Set(["fp_a", "fp_b", "fp_c"])); }); it("resolves fingerprints from direct cert IDs", () => { const roleFpMap = new Map>(); const certFpMap = new Map([ [10, "fp_x"], [20, "fp_y"], ]); const rule = makeRule({ allowedCertIds: [10, 20] }); const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap); expect(result).toEqual(new Set(["fp_x", "fp_y"])); }); it("unions both roles and certs", () => { const roleFpMap = new Map>([ [1, new Set(["fp_a"])], ]); const certFpMap = new Map([[10, "fp_b"]]); const rule = makeRule({ allowedRoleIds: [1], allowedCertIds: [10] }); const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap); expect(result).toEqual(new Set(["fp_a", "fp_b"])); }); it("deduplicates when a cert is in a role AND directly allowed", () => { const roleFpMap = new Map>([ [1, new Set(["fp_a"])], ]); const certFpMap = new Map([[10, "fp_a"]]); const rule = makeRule({ allowedRoleIds: [1], allowedCertIds: [10] }); const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap); expect(result.size).toBe(1); expect(result.has("fp_a")).toBe(true); }); it("returns empty set for unknown role/cert IDs", () => { const roleFpMap = new Map>(); const certFpMap = new Map(); const rule = makeRule({ allowedRoleIds: [999], allowedCertIds: [999] }); const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap); expect(result.size).toBe(0); }); }); // ── buildFingerprintCelExpression ──────────────────────────────────── describe("buildFingerprintCelExpression", () => { it("builds CEL expression with sorted fingerprints", () => { const fps = new Set(["fp_b", "fp_a"]); const expr = buildFingerprintCelExpression(fps); expect(expr).toBe("{http.request.tls.client.fingerprint} in ['fp_a', 'fp_b']"); }); it("handles single fingerprint", () => { const fps = new Set(["abc123"]); const expr = buildFingerprintCelExpression(fps); expect(expr).toBe("{http.request.tls.client.fingerprint} in ['abc123']"); }); }); // ── buildMtlsRbacSubroutes ────────────────────────────────────────── describe("buildMtlsRbacSubroutes", () => { const baseHandlers = [{ handler: "headers" }]; const reverseProxy = { handler: "reverse_proxy" }; it("returns null for empty rules", () => { const result = buildMtlsRbacSubroutes( [], new Map(), new Map(), baseHandlers, reverseProxy ); expect(result).toBeNull(); }); it("generates allow + deny routes for a role-based rule", () => { const roleFpMap = new Map>([ [1, new Set(["fp_admin"])], ]); const rules = [makeRule({ allowedRoleIds: [1] })]; const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy); expect(result).not.toBeNull(); // Should have 3 routes: allow /admin/*, deny /admin/*, catch-all expect(result!.length).toBe(3); // Allow route has expression matcher const allowRoute = result![0] as Record; const match = (allowRoute.match as Record[])[0]; expect(match.path).toEqual(["/admin/*"]); expect(match.expression).toContain("fp_admin"); expect(allowRoute.terminal).toBe(true); // Deny route returns 403 const denyRoute = result![1] as Record; const denyMatch = (denyRoute.match as Record[])[0]; expect(denyMatch.path).toEqual(["/admin/*"]); const denyHandler = (denyRoute.handle as Record[])[0]; expect(denyHandler.status_code).toBe("403"); // Catch-all has no match const catchAll = result![2] as Record; expect(catchAll.match).toBeUndefined(); expect(catchAll.terminal).toBe(true); }); it("generates 403 for denyAll rule", () => { const rules = [makeRule({ denyAll: true })]; const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy); expect(result).not.toBeNull(); // deny route + catch-all = 2 expect(result!.length).toBe(2); const denyRoute = result![0] as Record; const handler = (denyRoute.handle as Record[])[0]; expect(handler.status_code).toBe("403"); }); it("generates 403 when rule has no matching fingerprints", () => { const rules = [makeRule({ allowedRoleIds: [999] })]; // role doesn't exist const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy); expect(result).not.toBeNull(); // deny route + catch-all = 2 expect(result!.length).toBe(2); const denyRoute = result![0] as Record; const handler = (denyRoute.handle as Record[])[0]; expect(handler.status_code).toBe("403"); }); it("handles multiple rules with different paths", () => { const roleFpMap = new Map>([ [1, new Set(["fp_admin"])], [2, new Set(["fp_api"])], ]); const rules = [ makeRule({ pathPattern: "/admin/*", allowedRoleIds: [1] }), makeRule({ pathPattern: "/api/*", allowedRoleIds: [1, 2] }), ]; const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy); expect(result).not.toBeNull(); // 2 rules × 2 routes each + 1 catch-all = 5 expect(result!.length).toBe(5); }); it("uses direct cert fingerprints as overrides", () => { const certFpMap = new Map([[42, "fp_special"]]); const rules = [makeRule({ allowedCertIds: [42] })]; const result = buildMtlsRbacSubroutes(rules, new Map(), certFpMap, baseHandlers, reverseProxy); expect(result).not.toBeNull(); const allowRoute = result![0] as Record; const match = (allowRoute.match as Record[])[0]; expect(match.expression).toContain("fp_special"); }); it("catch-all route includes base handlers + reverse proxy", () => { const rules = [makeRule({ denyAll: true })]; const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy); const catchAll = result![result!.length - 1] as Record; const handlers = catchAll.handle as Record[]; expect(handlers).toHaveLength(2); // baseHandlers[0] + reverseProxy expect(handlers[0]).toEqual({ handler: "headers" }); expect(handlers[1]).toEqual({ handler: "reverse_proxy" }); }); it("allow route includes base handlers + reverse proxy", () => { const roleFpMap = new Map>([[1, new Set(["fp"])]]); const rules = [makeRule({ allowedRoleIds: [1] })]; const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy); const allowRoute = result![0] as Record; const handlers = allowRoute.handle as Record[]; expect(handlers).toHaveLength(2); expect(handlers[1]).toEqual({ handler: "reverse_proxy" }); }); it("deny route body is 'mTLS access denied'", () => { const rules = [makeRule({ denyAll: true })]; const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy); const denyHandler = (result![0] as any).handle[0]; expect(denyHandler.body).toBe("mTLS access denied"); }); it("handles mixed denyAll and role-based rules", () => { const roleFpMap = new Map>([[1, new Set(["fp"])]]); const rules = [ makeRule({ pathPattern: "/secret/*", denyAll: true }), makeRule({ pathPattern: "/api/*", allowedRoleIds: [1] }), ]; const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy); // /secret/* deny + /api/* allow + /api/* deny + catch-all = 4 expect(result!.length).toBe(4); // First route: deny /secret/* expect((result![0] as any).match[0].path).toEqual(["/secret/*"]); expect((result![0] as any).handle[0].status_code).toBe("403"); // Second route: allow /api/* expect((result![1] as any).match[0].path).toEqual(["/api/*"]); expect((result![1] as any).match[0].expression).toContain("fp"); }); it("handles rule with both roles and certs combined", () => { const roleFpMap = new Map>([[1, new Set(["fp_role"])]]); const certFpMap = new Map([[42, "fp_cert"]]); const rules = [makeRule({ allowedRoleIds: [1], allowedCertIds: [42] })]; const result = buildMtlsRbacSubroutes(rules, roleFpMap, certFpMap, baseHandlers, reverseProxy); const match = (result![0] as any).match[0]; expect(match.expression).toContain("fp_role"); expect(match.expression).toContain("fp_cert"); }); it("preserves base handlers order in generated routes", () => { const multiHandlers = [{ handler: "waf" }, { handler: "headers" }, { handler: "auth" }]; const roleFpMap = new Map>([[1, new Set(["fp"])]]); const rules = [makeRule({ allowedRoleIds: [1] })]; const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), multiHandlers, reverseProxy); const allowHandlers = (result![0] as any).handle; expect(allowHandlers[0]).toEqual({ handler: "waf" }); expect(allowHandlers[1]).toEqual({ handler: "headers" }); expect(allowHandlers[2]).toEqual({ handler: "auth" }); expect(allowHandlers[3]).toEqual({ handler: "reverse_proxy" }); }); }); // ── normalizeFingerprint edge cases ────────────────────────────────── describe("normalizeFingerprint edge cases", () => { it("handles full SHA-256 fingerprint with colons", () => { const fp = "AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89"; expect(normalizeFingerprint(fp)).toBe("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); }); it("handles mixed case without colons", () => { expect(normalizeFingerprint("AbCdEf")).toBe("abcdef"); }); it("handles fingerprint with only colons", () => { expect(normalizeFingerprint(":::")).toBe(""); }); }); // ── buildFingerprintCelExpression edge cases ───────────────────────── describe("buildFingerprintCelExpression edge cases", () => { it("handles empty fingerprint set", () => { const expr = buildFingerprintCelExpression(new Set()); expect(expr).toBe("{http.request.tls.client.fingerprint} in []"); }); it("handles many fingerprints", () => { const fps = new Set(Array.from({ length: 50 }, (_, i) => `fp_${String(i).padStart(3, "0")}`)); const expr = buildFingerprintCelExpression(fps); expect(expr).toContain("fp_000"); expect(expr).toContain("fp_049"); // Verify sorted order const idx0 = expr.indexOf("fp_000"); const idx49 = expr.indexOf("fp_049"); expect(idx0).toBeLessThan(idx49); }); }); // ── resolveAllowedFingerprints edge cases ──────────────────────────── describe("resolveAllowedFingerprints edge cases", () => { it("handles empty arrays in rule", () => { const rule = makeRule({ allowedRoleIds: [], allowedCertIds: [] }); const result = resolveAllowedFingerprints(rule, new Map(), new Map()); expect(result.size).toBe(0); }); it("handles role with empty fingerprint set", () => { const roleFpMap = new Map>([[1, new Set()]]); const rule = makeRule({ allowedRoleIds: [1] }); const result = resolveAllowedFingerprints(rule, roleFpMap, new Map()); expect(result.size).toBe(0); }); it("merges fingerprints from multiple roles correctly", () => { const roleFpMap = new Map>([ [1, new Set(["a", "b"])], [2, new Set(["b", "c"])], [3, new Set(["c", "d"])], ]); const rule = makeRule({ allowedRoleIds: [1, 2, 3] }); const result = resolveAllowedFingerprints(rule, roleFpMap, new Map()); expect(result).toEqual(new Set(["a", "b", "c", "d"])); }); });