import { describe, expect, it, vi } from "vitest"; import { __test__, createAuditEntry } from "../lib/audit.js"; const { redactSensitive } = __test__; describe("audit log redaction", () => { describe("redactSensitive", () => { it("redacts top-level password fields", () => { const result = redactSensitive({ userId: "u1", password: "hunter2" }); expect(result).toEqual({ userId: "u1", password: "[REDACTED]" }); }); it("redacts nested password fields", () => { const result = redactSensitive({ params: { userId: "u1", password: "hunter2" }, executed: true, }); expect(result).toEqual({ params: { userId: "u1", password: "[REDACTED]" }, executed: true, }); }); it("redacts password inside arrays", () => { const result = redactSensitive({ users: [ { id: "1", password: "secret" }, { id: "2", password: "other" }, ], }); expect(result).toEqual({ users: [ { id: "1", password: "[REDACTED]" }, { id: "2", password: "[REDACTED]" }, ], }); }); it("is case-insensitive", () => { const result = redactSensitive({ Password: "x", PASSWORD: "y", newPassword: "z", currentPassword: "a", }); expect(result).toEqual({ Password: "[REDACTED]", PASSWORD: "[REDACTED]", newPassword: "[REDACTED]", currentPassword: "[REDACTED]", }); }); it("redacts tokens, secrets, and cookies", () => { const result = redactSensitive({ token: "t", accessToken: "a", refreshToken: "r", apiKey: "k", secret: "s", totpSecret: "ts", authorization: "Bearer x", cookie: "sid=abc", }); for (const v of Object.values(result as Record)) { expect(v).toBe("[REDACTED]"); } }); it("leaves non-sensitive fields untouched", () => { const result = redactSensitive({ name: "Alice", email: "a@b.c", count: 42, flag: true }); expect(result).toEqual({ name: "Alice", email: "a@b.c", count: 42, flag: true }); }); it("handles null, undefined, and primitives", () => { expect(redactSensitive(null)).toBe(null); expect(redactSensitive(undefined)).toBe(undefined); expect(redactSensitive("string")).toBe("string"); expect(redactSensitive(123)).toBe(123); }); it("stops recursion at MAX_REDACT_DEPTH", () => { // Build a ~15-deep nested object; redaction should still work near the // top but bail past the depth limit without throwing. let v: Record = { password: "leaf" }; for (let i = 0; i < 15; i++) { v = { nested: v }; } expect(() => redactSensitive(v)).not.toThrow(); }); }); describe("createAuditEntry", () => { it("redacts passwords in `after` before the DB write", async () => { const create = vi.fn().mockResolvedValue({}); const db = { auditLog: { create } }; await createAuditEntry({ db: db as never, entityType: "AiToolExecution", entityId: "call_1", action: "CREATE", after: { params: { userId: "u1", password: "cleartext" }, executed: true }, }); expect(create).toHaveBeenCalledTimes(1); const data = create.mock.calls[0]![0]!.data; const changes = data.changes as { after?: { params?: { password?: string } } }; expect(changes.after?.params?.password).toBe("[REDACTED]"); expect(changes.after?.params).toMatchObject({ userId: "u1" }); }); it("redacts passwords in before/after when non-sensitive fields also changed", async () => { const create = vi.fn().mockResolvedValue({}); const db = { auditLog: { create } }; await createAuditEntry({ db: db as never, entityType: "User", entityId: "u1", action: "UPDATE", before: { password: "old", name: "Alice" }, after: { password: "new", name: "Bob" }, }); expect(create).toHaveBeenCalledTimes(1); const changes = create.mock.calls[0]![0]!.data.changes as { before?: Record; after?: Record; diff?: Record; }; expect(changes.before?.["password"]).toBe("[REDACTED]"); expect(changes.after?.["password"]).toBe("[REDACTED]"); // The name change survives in the diff, but the password diff collapses // (both values are the same placeholder). expect(changes.diff).toEqual({ name: { old: "Alice", new: "Bob" } }); }); it("skips UPDATE when both snapshots redact to the same value (empty diff)", async () => { const create = vi.fn().mockResolvedValue({}); const db = { auditLog: { create } }; await createAuditEntry({ db: db as never, entityType: "User", entityId: "u1", action: "UPDATE", before: { password: "old" }, after: { password: "new" }, }); // Both redact to [REDACTED], diff is empty, create should NOT be called. expect(create).not.toHaveBeenCalled(); }); it("redacts sensitive fields in metadata", async () => { const create = vi.fn().mockResolvedValue({}); const db = { auditLog: { create } }; await createAuditEntry({ db: db as never, entityType: "Webhook", entityId: "wh_1", action: "CREATE", after: { url: "https://example.com/hook" }, metadata: { signingSecret: "ss", apiKey: "leak" }, }); const changes = create.mock.calls[0]![0]!.data.changes as { metadata?: Record; }; expect(changes.metadata?.["apiKey"]).toBe("[REDACTED]"); // signingSecret is not in the set — verify the list is intentional expect(changes.metadata?.["signingSecret"]).toBe("ss"); }); }); });