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

@@ -4,6 +4,8 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
},
toJson: <T,>(v: T) => v,
}));
@@ -45,6 +47,7 @@ beforeEach(() => {
healthKitLastSyncedAt: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -116,3 +119,83 @@ describe("PATCH /api/integrations/healthkit", () => {
expect(ids).not.toContain("totallyUnknown");
});
});
describe("PATCH /api/integrations/healthkit — 422 multi-issue (v1.4.43 W6)", () => {
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/integrations/healthkit", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Two bad entries: first with bad direction, second with empty id.
const res = await PATCH(
patchReq({
entries: [
{ id: "bodyMass", direction: "JUNK" },
{ id: "", direction: "readOnly" },
],
}),
);
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 () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(
patchReq({
entries: [
{ id: "bodyMass", direction: "JUNK1" },
{ id: "", direction: "JUNK2" },
{ id: "weight", direction: "JUNK3" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed integrations.healthkit.validation-failed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
);
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("integrations.healthkit.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PATCH(
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
);
expect(res.status).toBe(422);
});
});
+26 -2
View File
@@ -11,7 +11,12 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -139,7 +144,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS HealthKit ingest is a hot path. Multi-issue
// 422 + audit breadcrumb so the iOS Sync engine sees the full
// diff and stops iterating one field at a time.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "integrations.healthkit.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const row = await prisma.user.findUnique({
@@ -0,0 +1,109 @@
/**
* v1.4.43 W14 — POST /api/integrations/withings/resume
*
* Three pins:
* 1. Happy path: a parked row resumes, response carries
* `wasParked: true`.
* 2. Idempotent: a connected row resumes silently, response carries
* `wasParked: false`.
* 3. Rate-limit: a 6th call within the window returns 429 with the
* `rate_limited_self` error code.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
resumeIntegrationFromPark: vi.fn(),
}));
import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
interface ApiEnvelope<T> {
data: T | null;
error: string | null;
}
function emptyRequest(): Request {
return new Request("http://localhost/api/integrations/withings/resume", {
method: "POST",
headers: { "content-length": "0" },
});
}
beforeEach(() => {
vi.mocked(resumeIntegrationFromPark).mockReset();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
});
describe("POST /api/integrations/withings/resume", () => {
it("clears a parked integration and reports wasParked: true", async () => {
vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({
wasParked: true,
});
const response = await POST();
expect(response.status).toBe(200);
const body = (await response.json()) as ApiEnvelope<{
resumed: boolean;
wasParked: boolean;
}>;
expect(body.data).toEqual({ resumed: true, wasParked: true });
expect(resumeIntegrationFromPark).toHaveBeenCalledWith("u-1", "withings");
});
it("returns wasParked: false when the row was already connected", async () => {
vi.mocked(resumeIntegrationFromPark).mockResolvedValueOnce({
wasParked: false,
});
const response = await POST();
expect(response.status).toBe(200);
const body = (await response.json()) as ApiEnvelope<{
resumed: boolean;
wasParked: boolean;
}>;
expect(body.data).toEqual({ resumed: true, wasParked: false });
});
it("returns 429 with rate_limited_self when the rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST();
expect(response.status).toBe(429);
const body = (await response.json()) as ApiEnvelope<unknown> & {
meta?: { errorCode?: string };
};
expect(body.error).toBeTruthy();
expect(body.meta?.errorCode).toBe("rate_limited_self");
expect(resumeIntegrationFromPark).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
/**
* v1.4.43 W14 — manual resume endpoint for a parked Withings
* integration.
*
* Background: B4 introduced the `parked` state in
* `src/lib/integrations/status.ts`. Once a Withings persistent-failure
* streak (contract-mismatch 293/294) has been running for more than
* 24h, the status writer flips the row to `parked` and the sync
* entry-point short-circuits via `isReauthRequired`. The user (or an
* operator) calls this endpoint to clear the park and let the next
* cron tick attempt the sync again.
*
* Rate-limited (5/min per user) to match the existing
* `/api/integrations/withings/test` endpoint — there's no upstream
* call here, but a misbehaving client tab loop would still rack up
* audit-log rows if the limit was absent.
*
* Returns 200 with `{ resumed: true, wasParked: boolean }`. The
* `wasParked` flag lets the caller decide whether to surface a "you
* resumed it" toast or treat the action as a no-op (the row was
* already connected / disconnected). Either way the endpoint is
* idempotent — a second click in the same second sees the same
* post-state and writes no extra audit row.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.withings.resume" } });
const rl = await checkRateLimit(
`withings-resume:${user.id}`,
5,
60_000,
);
if (!rl.allowed) {
return apiError("Too many resume requests", 429, {
errorCode: "rate_limited_self",
});
}
const result = await resumeIntegrationFromPark(user.id, "withings");
annotate({
meta: {
withings_resume_was_parked: result.wasParked,
},
});
return apiSuccess({ resumed: true, wasParked: result.wasParked });
});