chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged

Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-21 23:13:21 +02:00
1 parent 324d4bb411
commit 6c7fc7dc84
167 files changed
+11233 -371

No files matched your search

@@ -0,0 +1,148 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT
* /api/dashboard/chart-overlay-prefs.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardWidgets: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function putReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/dashboard/chart-overlay-prefs", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
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)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `chartKey` enum + `prefs.showTrendIndicator` not a boolean.
const res = await PUT(
putReq({
chartKey: "not-a-known-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Bad chartKey + bad showTrendIndicator + bad comparisonBaseline.
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "tomorrow",
},
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a dashboard.chart-overlay.validation-failed audit row", async () => {
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("dashboard.chart-overlay.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
expect(res.status).toBe(422);
});
});
@@ -16,7 +16,13 @@
* same request.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
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 {
@@ -49,7 +55,34 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const parsed = prefsSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// 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`, 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 },
});
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);
}
// Read-modify-write inside a Serializable transaction so two
+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),