mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user