diff --git a/backend/src/lib/audit-security.js b/backend/src/lib/audit-security.js index 14602d37..d7268bae 100644 --- a/backend/src/lib/audit-security.js +++ b/backend/src/lib/audit-security.js @@ -36,6 +36,16 @@ function stableStringify(value, depth = 0, seen = new WeakSet()) { if (value === null || value === undefined) return "null"; if (typeof value !== "object") return JSON.stringify(value); + // Date has no own enumerable properties, so the generic Object.entries() + // path below silently serializes any Date to "{}", discarding the actual + // timestamp. old_value/new_value in a profile-change audit event can be a + // Date (e.g. a timestamp field changing), so this must be special-cased + // before the object/array branches — otherwise the audit trail records a + // meaningless "{}" instead of the value that actually changed. + if (value instanceof Date) { + return JSON.stringify(value.toISOString()); + } + if (seen.has(value)) { return '"[Circular]"'; } diff --git a/backend/src/lib/audit-security.test.js b/backend/src/lib/audit-security.test.js index 52234938..d188bccf 100644 --- a/backend/src/lib/audit-security.test.js +++ b/backend/src/lib/audit-security.test.js @@ -23,6 +23,21 @@ describe("audit-security", () => { expect(value).toBe('{"a":1,"b":2}'); }); + it("preserves the timestamp when sanitizing a Date value (#1331)", () => { + // Date has no own enumerable properties, so the generic object-serialization + // path used to silently collapse any Date into "{}", losing the actual + // value entirely — e.g. a profile-change audit event recording a + // timestamp field change. + const date = new Date("2026-01-01T12:00:00.000Z"); + expect(sanitizeAuditValue(date)).toBe('"2026-01-01T12:00:00.000Z"'); + }); + + it("produces different hashes for payloads that differ only by Date value (#1331)", () => { + const before = hashAuditPayload({ old_value: new Date("2026-01-01T00:00:00.000Z") }); + const after = hashAuditPayload({ old_value: new Date("2026-06-01T00:00:00.000Z") }); + expect(before).not.toBe(after); + }); + it("redacts sensitive audit field names", () => { expect(sanitizeAuditKey("api_key")).toBe("[REDACTED]"); expect(sanitizeAuditKey("notification_email")).toBe("notification_email");