diff --git a/src/features/housekeeping/foundation/commands/audit-envelope.test.ts b/src/features/housekeeping/foundation/commands/audit-envelope.test.ts index 14e165085c..38b534fcff 100644 --- a/src/features/housekeeping/foundation/commands/audit-envelope.test.ts +++ b/src/features/housekeeping/foundation/commands/audit-envelope.test.ts @@ -96,4 +96,37 @@ describe("audit command envelope", () => { undefined, ); }); + it("preserves the original operation error when failure evidence rejects", async () => { + const original = new Error("operation failed"); + const auditFailure = new Error("audit failure"); + const writer: HousekeepingAuditWriter = { + write: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(auditFailure), + }; + await expect( + runWithAuditIntent(writer, auditEntry, () => Promise.reject(original)), + ).rejects.toBe(original); + expect( + (original as Error & { auditOutcomeError?: unknown }).auditOutcomeError, + ).toBe(auditFailure); + }); + + it("throws a completed-operation error when success evidence rejects", async () => { + const auditFailure = new Error("audit failure"); + const writer: HousekeepingAuditWriter = { + write: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(auditFailure), + }; + await expect( + runWithAuditIntent(writer, auditEntry, () => Promise.resolve("done")), + ).rejects.toMatchObject({ + operationCompleted: true, + operationResult: "done", + auditOutcomeError: auditFailure, + }); + }); }); diff --git a/src/features/housekeeping/foundation/commands/audit-envelope.ts b/src/features/housekeeping/foundation/commands/audit-envelope.ts index c2780ec1f6..fdf08dfbdd 100644 --- a/src/features/housekeeping/foundation/commands/audit-envelope.ts +++ b/src/features/housekeeping/foundation/commands/audit-envelope.ts @@ -5,6 +5,17 @@ import type { } from "@/lib/services/audit"; type AuditOutcome = Exclude, "intent">; +export class AuditOutcomePersistenceError extends Error { + readonly operationCompleted = true; + + constructor( + readonly operationResult: T, + readonly auditOutcomeError: unknown, + ) { + super("Operation completed but audit outcome could not be persisted"); + this.name = "AuditOutcomePersistenceError"; + } +} export async function writeIntent( writer: HousekeepingAuditWriter, @@ -35,10 +46,23 @@ export async function runWithAuditIntent( try { result = await operation(); } catch (error) { - await writeOutcome(writer, entry, "failure", transaction); + try { + await writeOutcome(writer, entry, "failure", transaction); + } catch (auditOutcomeError) { + if (error instanceof Error) { + Object.defineProperty(error, "auditOutcomeError", { + value: auditOutcomeError, + configurable: true, + }); + } + } throw error; } - await writeOutcome(writer, entry, "success", transaction); + try { + await writeOutcome(writer, entry, "success", transaction); + } catch (auditOutcomeError) { + throw new AuditOutcomePersistenceError(result, auditOutcomeError); + } return result; } diff --git a/src/lib/services/audit.test.ts b/src/lib/services/audit.test.ts index 5ed9fb75d1..b8a7e45352 100644 --- a/src/lib/services/audit.test.ts +++ b/src/lib/services/audit.test.ts @@ -237,4 +237,21 @@ describe("getAuditLogs", () => { const result = await getAuditLogs(); expect(result.rows[0].username).toBe("User #99"); }); + it("fails closed beyond the redaction depth cap without mutating the input", async () => { + const sentinel = "raw-depth-secret"; + const deep = { + a: { b: { c: { d: { e: { f: { g: { secret: sentinel } } } } } } }, + }; + insertValues.mockResolvedValue({ id: 1 }); + await logAudit({ + userId: 1, + action: "update", + target: "user", + before: { deep, state: "before" }, + after: { deep, state: "after" }, + }); + const data = insertValues.mock.calls[0][0]; + expect(`${data.before}${data.after}${data.diff}`).not.toContain(sentinel); + expect(deep.a.b.c.d.e.f.g.secret).toBe(sentinel); + }); }); diff --git a/src/lib/services/audit.ts b/src/lib/services/audit.ts index b33148920c..488e37b54b 100644 --- a/src/lib/services/audit.ts +++ b/src/lib/services/audit.ts @@ -30,7 +30,8 @@ const SENSITIVE_KEY_RE = const REDACTED = "[Redacted]"; function sanitizeAuditPayload(value: unknown, depth = 0): unknown { - if (depth > 6 || value == null) return value; + if (depth > 6) return REDACTED; + if (value == null) return value; if (Array.isArray(value)) return value.map((v) => sanitizeAuditPayload(v, depth + 1)); if (typeof value !== "object") return value; @@ -108,8 +109,8 @@ export async function getAuditLogs(options: GetLogsOptions = {}) { const where = search ? or( - like(AdminAuditLog.action, `%${search}%`), - like(AdminAuditLog.target, `%${search}%`), + like(AdminAuditLog.action, `%$search%`), + like(AdminAuditLog.target, `%$search%`), ) : undefined;