mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(widgets): dedup validation-failed audit rows per user 60s
A misbehaving iOS client retrying a 422 in a tight loop could write thousands of dashboard.widgets.validation-failed audit rows per minute. Gate the audit-ledger write behind an in-process (userId, action) memo with a 60 s window so the breadcrumb stays useful for grep traffic without flooding the ledger. The 422 response itself is unchanged -- every retry still receives the full multi-issue envelope. Cluster deployments cap at one row per process per minute; a process restart resets the map (one extra row worst case).
This commit is contained in:
1 parent
825284c724
commit
f9cf396f2e
2 files changed
+79
-13
No files matched your search
@@ -45,7 +45,7 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { PUT, __resetAuditDedupMemoForTests } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
@@ -73,6 +73,7 @@ function makeReq(body: unknown): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
__resetAuditDedupMemoForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
@@ -144,6 +145,26 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
}
|
||||
});
|
||||
|
||||
it("dedups the audit-ledger write across two sequential 422s for the same user (v1.4.43 B2)", async () => {
|
||||
// First 422 writes one row; the second 422 inside the 60 s window
|
||||
// returns the same envelope but skips the audit insert.
|
||||
const res1 = await callPut(makeReq({ version: 2, widgets: [] }));
|
||||
expect(res1.status).toBe(422);
|
||||
const res2 = await callPut(makeReq({ version: 2, widgets: [] }));
|
||||
expect(res2.status).toBe(422);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The full multi-issue envelope still rides on every 422 — the
|
||||
// dedup only suppresses the breadcrumb write, never the response.
|
||||
const body2 = (await res2.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body2.details.issues.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not block the 422 response when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
|
||||
@@ -42,6 +42,40 @@ 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();
|
||||
}
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z
|
||||
@@ -143,18 +177,29 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "dashboard.widgets.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.widgets.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — validation response is the contract, audit row is best-effort */
|
||||
});
|
||||
// v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup
|
||||
// so a misbehaving iOS client retrying every second cannot pump
|
||||
// thousands of identical rows into the audit ledger.
|
||||
if (
|
||||
shouldEmitAuditRow(
|
||||
user.id,
|
||||
"dashboard.widgets.validation-failed",
|
||||
Date.now(),
|
||||
)
|
||||
) {
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.widgets.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — validation response is the contract, audit row is best-effort */
|
||||
});
|
||||
}
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user