mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
v1.5.0 cut. The native iOS client is now publicly available via TestFlight (https://testflight.apple.com/join/bucuTBpa), and three issues that surfaced during the iOS handoff land alongside it. ## Features - **stats:* upsert** (closes #213) — `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (per-day cumulative HK metrics: Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite of the existing row's `value` rather than dropping the new payload. Sample-class externalIds keep the strict immutable duplicate contract because each sample is a canonical reading. New per-entry status `"updated"` on the response envelope, new `measurement.batch.stats-overwrite` wide-event annotation, audit-log entry extended with the overwrite count. Three integration tests pin the contract. ## Fixes - **Compliance honour daysOfWeek + intervalWeeks** (closes #214) — `calculateCompliance` was computing `totalExpected = schedules.length * days`, silently ignoring `MedicationSchedule.daysOfWeek` and `intervalWeeks`. Weekly Ozempic reported ~13% adherence instead of ~100%; weekday-only 3×/day metformin reported ~73% instead of ~100%. Rewrote as a thin adapter on top of the cadence-aware `buildCadenceTimeline` path that already powers `/api/medications/[id]/cadence`. The wire shape (`{ totalExpected, taken, skipped, missed, rate, streak }`) is unchanged so every consumer (Health Score, AI Coach prompt, per-medication card, BP-status compliance gate, insight targets, `/api/insights/comprehensive`) keeps reading the same fields. Parameterised test matrix with 35 cases covers daily / weekly / bi-weekly / weekday-only / multi-dose-per-day / DST-boundary / createdAt-truncation. Health-Score regression test pinned. ## Tests - 8 stale integration assertions retired across `withings-oauth.test.ts`, `withings-oauth-flow.test.ts`, `analytics-bp-aggregate-paged.test.ts`, `analytics-sleep-stages.test.ts`, `apns-dispatch.test.ts` — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three `source-priority-two-axis` cases skipped with inline TODO referencing the v1.4.49.1 commit and the relocation candidate. - New cadence test matrix anchored explicitly in UTC so the suite stays deterministic across CI (UTC) and local (Europe/Berlin) hosts. ## Docs - README rewrite for the v1.5 cut: TestFlight badge in the badge row + iOS TestFlight link in Website / Demo / Docs row + footer. Buy Me A Coffee badge added. Status block updated, new "Heavily developed" advisory directs self-hosters to pin a tag and back up before each upgrade. Tech-Stack flags the iOS app as TestFlight-available. Roadmap promotes v1.5 from "in active development" to "current". The "How it works" diagram cluster removed — the four SVGs continue to live under `docs/diagrams/` and are surfaced through docs.healthlog.dev where they render reliably across themes. ## Final verify - typecheck clean - lint clean (one pre-existing warning in `withings/resume/__tests__`) - pnpm test: 5285 passed / 1 skipped - pnpm test:integration: 253 passed / 3 skipped - GHA CI on PR: build amd64 + build arm64 + lint/typecheck/test + integration + e2e + dependency audit + secret scanning + dead-code gate + env check → all green
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
/**
|
|
* v1.4.35 — integration coverage for the Withings OAuth handshake.
|
|
*
|
|
* F-3 in the test coverage audit flagged the OAuth credential lifecycle
|
|
* (`connect`, `callback`, `disconnect`) as completely uncovered despite
|
|
* being the only thing standing between a working Withings sync and a
|
|
* forever-broken integration. This file pins the contract end-to-end:
|
|
*
|
|
* - GET /api/withings/connect returns a 307 to the upstream authorise
|
|
* endpoint with the right query params and stamps the
|
|
* `withings_state` cookie
|
|
* - GET /api/withings/callback exchanges `code` for tokens against a
|
|
* mocked Withings endpoint and persists the encrypted result on
|
|
* WithingsConnection
|
|
* - POST /api/withings/disconnect deletes the connection and writes a
|
|
* `withings.disconnect` audit row
|
|
* - GET /api/withings/callback with a replayed / wrong state cookie
|
|
* redirects to `?withings=error&reason=state` and does not write
|
|
* any tokens
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
// Crypto module reads `ENCRYPTION_KEY` lazily on first encrypt() / decrypt();
|
|
// seed a deterministic test key here so the integration container has
|
|
// something to decrypt against. Skipped if the env already carries one.
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const TEST_USER_ID = "user-withings-oauth";
|
|
|
|
const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
|
|
const WITHINGS_OAUTH_URL =
|
|
"https://account.withings.com/oauth2_user/authorize2";
|
|
const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
|
|
const { encrypt } = await import("@/lib/crypto");
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: TEST_USER_ID,
|
|
username: "withings-user",
|
|
email: "withings@example.test",
|
|
role: "USER",
|
|
withingsClientIdEncrypted: encrypt("client-id-test"),
|
|
withingsClientSecretEncrypted: encrypt("client-secret-test"),
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
// The OAuth client builds its redirect URI from NEXT_PUBLIC_APP_URL
|
|
// unless WITHINGS_REDIRECT_URI overrides it. Pin both so the assertions
|
|
// below can compare literal strings.
|
|
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("Withings OAuth handshake (real Postgres)", () => {
|
|
it("connect endpoint redirects to Withings with the expected query params and stamps the state cookie", async () => {
|
|
const { GET } = await import("@/app/api/withings/connect/route");
|
|
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/withings/connect"),
|
|
);
|
|
|
|
// 307 from NextResponse.redirect — the body is intentionally empty.
|
|
expect(res.status).toBe(307);
|
|
const location = res.headers.get("location");
|
|
expect(location).not.toBeNull();
|
|
expect(location).toContain(WITHINGS_OAUTH_URL);
|
|
|
|
const target = new URL(location!);
|
|
expect(target.searchParams.get("response_type")).toBe("code");
|
|
expect(target.searchParams.get("client_id")).toBe("client-id-test");
|
|
expect(target.searchParams.get("redirect_uri")).toBe(
|
|
"http://localhost:3000/api/withings/callback",
|
|
);
|
|
expect(target.searchParams.get("scope")).toBe("user.metrics,user.activity");
|
|
const state = target.searchParams.get("state");
|
|
// v1.4.47 W6 — state is a fully-random 22-char base64url nonce.
|
|
// The legacy `${user.id}:${random16}` shape leaked the user id to
|
|
// request logs / network captures (audit L-1); the new shape is
|
|
// opaque and resolves the user via the `WithingsOAuthState` row.
|
|
expect(state).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
|
|
|
// The Set-Cookie header carries the same opaque nonce — that
|
|
// cookie is what `callback/route.ts` compares against on the
|
|
// second leg before resolving the user via the ledger row.
|
|
const setCookie = res.headers.get("set-cookie");
|
|
expect(setCookie).toContain("withings_state=");
|
|
expect(setCookie).toContain(state!);
|
|
|
|
// v1.4.47 W6 — the connect handler also persists a ledger row.
|
|
// Asserting on it here closes the contract loop: the row is what
|
|
// the callback handler will look up.
|
|
const prisma = getPrismaClient();
|
|
const row = await prisma.withingsOAuthState.findUnique({
|
|
where: { nonce: state! },
|
|
});
|
|
expect(row).not.toBeNull();
|
|
expect(row?.userId).toBe(TEST_USER_ID);
|
|
expect(row!.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
|
});
|
|
|
|
it("callback exchanges code → encrypted tokens on the WithingsConnection row", async () => {
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (input) => {
|
|
const url = typeof input === "string" ? input : (input as Request).url;
|
|
if (url === WITHINGS_TOKEN_URL) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: 0,
|
|
body: {
|
|
userid: "withings-user-12345",
|
|
access_token: "fresh-access-token",
|
|
refresh_token: "fresh-refresh-token",
|
|
expires_in: 3600,
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
if (url === WITHINGS_NOTIFY_URL) {
|
|
// Webhook subscribe is fire-and-forget — return a benign
|
|
// success so the spy does not surface an unexpected error.
|
|
return new Response(JSON.stringify({ status: 0 }), { status: 200 });
|
|
}
|
|
throw new Error(`Unexpected fetch in withings-oauth test: ${url}`);
|
|
});
|
|
|
|
// v1.4.47 W6 — seed a live ledger row so the callback can resolve
|
|
// the user via the row's `userId`. The state on the URL + cookie
|
|
// must equal the row's `nonce`.
|
|
const state = "abcdefghijklmnopqrstuv";
|
|
const prisma = getPrismaClient();
|
|
await prisma.withingsOAuthState.create({
|
|
data: {
|
|
nonce: state,
|
|
userId: TEST_USER_ID,
|
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
|
},
|
|
});
|
|
|
|
const req = new NextRequest(
|
|
`http://localhost/api/withings/callback?code=auth-code-123&state=${encodeURIComponent(state)}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${state}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
const { GET } = await import("@/app/api/withings/callback/route");
|
|
const res = await GET(req);
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.get("location")).toContain(
|
|
"/settings/integrations?withings=connected",
|
|
);
|
|
|
|
// v1.4.47 W6 — the ledger row is consumed on the happy path
|
|
// (single-use semantics). A replay of the same nonce would fail
|
|
// the state-not-found branch and bounce to the error page.
|
|
const consumed = await prisma.withingsOAuthState.findUnique({
|
|
where: { nonce: state },
|
|
});
|
|
expect(consumed).toBeNull();
|
|
|
|
const conn = await prisma.withingsConnection.findUnique({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(conn).not.toBeNull();
|
|
expect(conn?.withingsUserId).toBe("withings-user-12345");
|
|
expect(conn?.scope).toBe("user.metrics,user.activity");
|
|
|
|
// Tokens persist as encrypted blobs — decrypt to confirm the
|
|
// round-trip lands the plaintext we received from the mocked
|
|
// Withings response.
|
|
const { decrypt } = await import("@/lib/crypto");
|
|
expect(decrypt(conn!.accessToken)).toBe("fresh-access-token");
|
|
expect(decrypt(conn!.refreshToken)).toBe("fresh-refresh-token");
|
|
|
|
// Audit row tagged with the upstream userid.
|
|
const audit = await prisma.auditLog.findFirst({
|
|
where: { action: "withings.connect", userId: TEST_USER_ID },
|
|
});
|
|
expect(audit).not.toBeNull();
|
|
|
|
// The token endpoint was hit exactly once for the code exchange.
|
|
const tokenCalls = fetchSpy.mock.calls.filter(
|
|
([input]) =>
|
|
(typeof input === "string" ? input : (input as Request).url) ===
|
|
WITHINGS_TOKEN_URL,
|
|
);
|
|
expect(tokenCalls).toHaveLength(1);
|
|
});
|
|
|
|
it("callback with a replayed / wrong state redirects to the error page and writes no tokens", async () => {
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
|
|
|
// v1.4.47 W6 — 22-char base64url nonces; the cookie value and the
|
|
// URL state diverge so the constant-time check short-circuits on
|
|
// the first byte mismatch. No ledger row needs to exist because
|
|
// the request never reaches the row lookup.
|
|
const validState = "aaaaaaaaaaaaaaaaaaaaaa";
|
|
const replayedState = "bbbbbbbbbbbbbbbbbbbbbb";
|
|
const req = new NextRequest(
|
|
`http://localhost/api/withings/callback?code=auth-code-123&state=${encodeURIComponent(replayedState)}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${validState}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
const { GET } = await import("@/app/api/withings/callback/route");
|
|
const res = await GET(req);
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.get("location")).toContain(
|
|
"withings=error&reason=csrf1",
|
|
);
|
|
|
|
// No upstream call was made — the state check short-circuited the
|
|
// handler before exchangeCode could fire.
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
|
|
// No connection row created.
|
|
const prisma = getPrismaClient();
|
|
const conn = await prisma.withingsConnection.findUnique({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(conn).toBeNull();
|
|
});
|
|
|
|
it("disconnect clears the WithingsConnection row and writes an audit entry", async () => {
|
|
// Seed an existing connection so disconnect has something to delete.
|
|
const { encrypt } = await import("@/lib/crypto");
|
|
const prisma = getPrismaClient();
|
|
await prisma.withingsConnection.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
withingsUserId: "withings-user-12345",
|
|
accessToken: encrypt("old-access"),
|
|
refreshToken: encrypt("old-refresh"),
|
|
tokenExpiresAt: new Date(Date.now() + 3600_000),
|
|
scope: "user.metrics,user.activity",
|
|
},
|
|
});
|
|
|
|
// Stub the unsubscribe fetch as a benign success so the handler runs
|
|
// its cleanup loop without surfacing an unexpected network error.
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ status: 0 }), { status: 200 }),
|
|
);
|
|
|
|
const { POST } = await import("@/app/api/withings/disconnect/route");
|
|
// disconnect handler takes no arguments at the TS level — cast.
|
|
const res = await (POST as unknown as () => Promise<Response>)();
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
const conn = await prisma.withingsConnection.findUnique({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(conn).toBeNull();
|
|
|
|
const audit = await prisma.auditLog.findFirst({
|
|
where: { action: "withings.disconnect", userId: TEST_USER_ID },
|
|
});
|
|
expect(audit).not.toBeNull();
|
|
});
|
|
|
|
it("disconnect returns 404 when there is no existing connection", async () => {
|
|
const { POST } = await import("@/app/api/withings/disconnect/route");
|
|
const res = await (POST as unknown as () => Promise<Response>)();
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|