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