mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
323 lines
12 KiB
TypeScript
323 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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
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 — 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 — 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 — 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 — 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 — 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);
|
|
});
|
|
});
|