mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user