refactor(audit): share dedup helper between widgets and chart-overlay-prefs

Extracted shouldEmitAuditRow + __resetAuditDedupMemoForTests to src/lib/audit-dedup.ts so the chart-overlay-prefs route inherits the same 60 s (userId, action) window the widgets route got in W8 B2. Prevents a misbehaving iOS client looping on the chart overlay popover from pumping thousands of dashboard.chart-overlay.validation-failed audit rows per minute.

v1.4.43 W10 high-reconcile — simplifier H-2.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 23:05:52 +02:00
1 parent 120e318cc7
commit 89071e03d9
4 files changed
+72 -45

No files matched your search

@@ -51,10 +51,13 @@ function putReq(body: unknown): NextRequest {
});
}
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
__resetAuditDedupMemoForTests();
});
describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6)", () => {
@@ -22,6 +22,7 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
@@ -57,23 +58,30 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// v1.4.43 W6 — sibling of `/api/dashboard/widgets`; the per-chart
// overlay popover hits this on every toggle so the multi-issue
// envelope matches widgets exactly. Audit breadcrumb keyed
// `dashboard.chart-overlay.validation-failed`.
// `dashboard.chart-overlay.validation-failed`, deduped via the
// shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a
// misbehaving iOS client looping the popover cannot flood the
// audit ledger.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "dashboard.chart-overlay.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.chart-overlay.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
if (
shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed")
) {
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.chart-overlay.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
}
return returnAllZodIssues(parsed.error, 422);
}
+5 -33
View File
@@ -42,39 +42,11 @@ import type { NextRequest } from "next/server";
// the enum from `DASHBOARD_WIDGET_IDS` so the two lists cannot drift.
const widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS);
// v1.4.43 B2 — in-process dedup memo for the validation-failed audit row.
// A misbehaving iOS client looping on a 422 can otherwise write thousands
// of identical audit rows per minute. We dedup on `(userId, action)` with
// a 60 s window: only the first 422 inside the window writes; subsequent
// 422s still return the full multi-issue envelope but skip the breadcrumb.
// In-memory only — a process restart resets the map, which is the safe
// failure mode (worst case: one extra audit row per restart). Cluster
// deployments still get one row per process per minute, which is fine for
// the operator-grep use case the audit row exists for.
const AUDIT_DEDUP_WINDOW_MS = 60_000;
const auditDedupMemo = new Map<string, number>();
function shouldEmitAuditRow(userId: string, action: string, now: number): boolean {
const key = `${userId}:${action}`;
const last = auditDedupMemo.get(key);
if (last !== undefined && now - last < AUDIT_DEDUP_WINDOW_MS) {
return false;
}
auditDedupMemo.set(key, now);
// Opportunistic GC — drop entries older than the window on every miss so
// the map cannot grow unbounded across a long-running process.
if (auditDedupMemo.size > 512) {
for (const [k, t] of auditDedupMemo) {
if (now - t >= AUDIT_DEDUP_WINDOW_MS) auditDedupMemo.delete(k);
}
}
return true;
}
/** @internal — exported for the dedup unit test only. */
export function __resetAuditDedupMemoForTests(): void {
auditDedupMemo.clear();
}
// v1.4.43 B2 — dedup helper extracted to `src/lib/audit-dedup.ts` so both
// `/api/dashboard/widgets` and `/api/dashboard/chart-overlay-prefs` share
// the same 60 s `(userId, action)` window.
export { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
const layoutSchema = z.object({
version: z.literal(1),
+44
View File
@@ -0,0 +1,44 @@
/**
* In-process dedup helper for fire-and-forget audit-log rows.
*
* A misbehaving iOS client looping on a 422 can otherwise write
* thousands of identical audit rows per minute. Routes that emit a
* `<route>.validation-failed` audit row alongside a multi-issue Zod
* 422 envelope wrap the write through `shouldEmitAuditRow` to gate
* on a 60 s `(userId, action)` window — only the first 422 inside
* the window writes; subsequent 422s still return the full envelope
* but skip the breadcrumb.
*
* In-memory only — a process restart resets the map, which is the
* safe failure mode (worst case: one extra audit row per restart).
* Cluster deployments still get one row per process per minute,
* which is fine for the operator-grep use case the audit row
* exists for.
*/
const AUDIT_DEDUP_WINDOW_MS = 60_000;
const auditDedupMemo = new Map<string, number>();
export function shouldEmitAuditRow(
userId: string,
action: string,
now: number = Date.now(),
): boolean {
const key = `${userId}:${action}`;
const last = auditDedupMemo.get(key);
if (last !== undefined && now - last < AUDIT_DEDUP_WINDOW_MS) {
return false;
}
auditDedupMemo.set(key, now);
if (auditDedupMemo.size > 512) {
for (const [k, t] of auditDedupMemo) {
if (now - t >= AUDIT_DEDUP_WINDOW_MS) auditDedupMemo.delete(k);
}
}
return true;
}
/** @internal — exported for unit tests only. */
export function __resetAuditDedupMemoForTests(): void {
auditDedupMemo.clear();
}