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.
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
/**
|
|
* v1.4.47 — end-to-end coverage for the new Withings OAuth state
|
|
* ledger.
|
|
*
|
|
* The audit's L-1 recommendation was a fully-random nonce backed by a
|
|
* `(nonce → userId)` ledger row, single-use semantics, with the
|
|
* cookie carrying only the opaque nonce. This file pins the
|
|
* connect → callback handshake end-to-end against real Postgres:
|
|
*
|
|
* 1. `withings/connect` mints a row and stamps the cookie.
|
|
* 2. `withings/callback` resolves the user via the row's `userId`,
|
|
* verifies it matches the session, and deletes the row.
|
|
* 3. A second callback with the same nonce never reaches the token
|
|
* endpoint: while the first callback's claim stands it follows
|
|
* that callback to the same page, and without one it hits the
|
|
* "row not found" branch and the error page (replay protection).
|
|
* 4. A callback whose row has expired hits the same branch.
|
|
* 5. The connect handler refuses to start when the user has no
|
|
* Withings credentials — no ledger row is minted.
|
|
*
|
|
* Sibling assertions in `withings-oauth.test.ts` already cover the
|
|
* token-exchange happy path + the `disconnect` route; this file
|
|
* focuses on the ledger lifecycle the audit flagged.
|
|
*/
|
|
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";
|
|
|
|
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-flow";
|
|
|
|
const WITHINGS_OAUTH_URL =
|
|
"https://account.withings.com/oauth2_user/authorize2";
|
|
const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
|
|
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-flow-user",
|
|
email: "withings-flow@example.test",
|
|
role: "USER",
|
|
withingsClientIdEncrypted: encrypt("client-id-flow"),
|
|
withingsClientSecretEncrypted: encrypt("client-secret-flow"),
|
|
},
|
|
});
|
|
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);
|
|
|
|
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("Withings OAuth nonce ledger (real Postgres)", () => {
|
|
it("connect mints exactly one row keyed on the URL state, with `userId` + 10-min TTL", 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"),
|
|
);
|
|
|
|
expect(res.status).toBe(307);
|
|
const location = res.headers.get("location")!;
|
|
expect(location).toContain(WITHINGS_OAUTH_URL);
|
|
const target = new URL(location);
|
|
const state = target.searchParams.get("state")!;
|
|
|
|
// 22-char base64url. The legacy `${userId}:${random16}` shape no
|
|
// longer reaches the URL — the audit L-1 fix.
|
|
expect(state).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
|
expect(state).not.toContain(":");
|
|
expect(state).not.toContain(TEST_USER_ID);
|
|
|
|
const prisma = getPrismaClient();
|
|
const rows = await prisma.withingsOAuthState.findMany();
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].nonce).toBe(state);
|
|
expect(rows[0].userId).toBe(TEST_USER_ID);
|
|
// TTL is 10 minutes; allow a generous floor so a slow CI box
|
|
// doesn't flake on the upper bound.
|
|
const ttlMs = rows[0].expiresAt.getTime() - Date.now();
|
|
expect(ttlMs).toBeGreaterThan(9 * 60 * 1000);
|
|
expect(ttlMs).toBeLessThanOrEqual(10 * 60 * 1000 + 5_000);
|
|
|
|
// Cookie carries the same opaque nonce — no user id.
|
|
const setCookie = res.headers.get("set-cookie")!;
|
|
expect(setCookie).toContain(`withings_state=${state}`);
|
|
expect(setCookie).not.toContain(TEST_USER_ID);
|
|
});
|
|
|
|
it("connect redirects to the settings error page (no dead-end) when the user has no Withings credentials — no ledger row minted", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.update({
|
|
where: { id: TEST_USER_ID },
|
|
data: {
|
|
withingsClientIdEncrypted: null,
|
|
withingsClientSecretEncrypted: null,
|
|
},
|
|
});
|
|
|
|
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"),
|
|
);
|
|
|
|
// The no-credentials branch now redirects to Settings → Integrations with
|
|
// a toast reason instead of emitting a raw JSON 400 into a browser tab
|
|
// (every other failure branch already redirects). The security property —
|
|
// no OAuth-state ledger row minted — still holds.
|
|
expect(res.status).toBe(307);
|
|
const location = res.headers.get("location") ?? "";
|
|
expect(location).toContain("withings=error");
|
|
expect(location).toContain("reason=nocreds");
|
|
const rows = await prisma.withingsOAuthState.findMany();
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it("connect → callback consumes the row (single-use); a repeat of the same nonce never exchanges again", async () => {
|
|
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-userid-flow",
|
|
access_token: "access-flow",
|
|
refresh_token: "refresh-flow",
|
|
expires_in: 3600,
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
if (url === WITHINGS_NOTIFY_URL) {
|
|
return new Response(JSON.stringify({ status: 0 }), { status: 200 });
|
|
}
|
|
throw new Error(`Unexpected fetch in flow test: ${url}`);
|
|
});
|
|
|
|
// Leg 1 — connect
|
|
const { GET: connect } = await import("@/app/api/withings/connect/route");
|
|
const connectRes = await (
|
|
connect as unknown as (req: Request) => Promise<Response>
|
|
)(new Request("http://localhost/api/withings/connect"));
|
|
const location = connectRes.headers.get("location")!;
|
|
const nonce = new URL(location).searchParams.get("state")!;
|
|
|
|
const prisma = getPrismaClient();
|
|
const beforeCallback = await prisma.withingsOAuthState.findUnique({
|
|
where: { nonce },
|
|
});
|
|
expect(beforeCallback).not.toBeNull();
|
|
|
|
// Leg 2 — callback (happy path)
|
|
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
|
const callbackReq = new NextRequest(
|
|
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
|
},
|
|
},
|
|
);
|
|
const callbackRes = await callback(callbackReq);
|
|
expect(callbackRes.status).toBe(307);
|
|
expect(callbackRes.headers.get("location")).toContain("withings=connected");
|
|
|
|
const afterCallback = await prisma.withingsOAuthState.findUnique({
|
|
where: { nonce },
|
|
});
|
|
expect(afterCallback).toBeNull();
|
|
|
|
// Leg 3 — the browser delivers the same callback again. The cookie
|
|
// compare still passes (matching strings) and the row is gone, but the
|
|
// first callback's claim stands: the repeat follows it to the same page
|
|
// without hitting the token endpoint again.
|
|
const fetchSpy = vi.mocked(globalThis.fetch);
|
|
fetchSpy.mockClear();
|
|
const replayRequest = () =>
|
|
new NextRequest(
|
|
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
|
},
|
|
},
|
|
);
|
|
const repeatRes = await callback(replayRequest());
|
|
expect(repeatRes.status).toBe(307);
|
|
expect(repeatRes.headers.get("location")).toContain("withings=connected");
|
|
|
|
// Leg 4 — once the claim has been swept, the same nonce is a plain
|
|
// replay and lands on the error page.
|
|
await prisma.oAuthCallbackClaim.deleteMany();
|
|
const replayRes = await callback(replayRequest());
|
|
expect(replayRes.status).toBe(307);
|
|
expect(replayRes.headers.get("location")).toContain(
|
|
"withings=error&reason=replay",
|
|
);
|
|
// No token-endpoint call on the replay leg.
|
|
const replayTokenCalls = fetchSpy.mock.calls.filter(
|
|
([input]) =>
|
|
(typeof input === "string" ? input : (input as Request).url) ===
|
|
WITHINGS_TOKEN_URL,
|
|
);
|
|
expect(replayTokenCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("callback rejects an expired row, deletes it, and never reaches the token exchange", async () => {
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
|
|
|
const nonce = "expired-nonce-22charXX";
|
|
const prisma = getPrismaClient();
|
|
await prisma.withingsOAuthState.create({
|
|
data: {
|
|
nonce,
|
|
userId: TEST_USER_ID,
|
|
expiresAt: new Date(Date.now() - 60_000), // already expired
|
|
},
|
|
});
|
|
|
|
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
|
const req = new NextRequest(
|
|
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
|
},
|
|
},
|
|
);
|
|
const res = await callback(req);
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.get("location")).toContain(
|
|
"withings=error&reason=expired",
|
|
);
|
|
|
|
// Token endpoint never hit. Scope the assertion to the token URL so a
|
|
// prior connection's fire-and-forget webhook subscriptions, which resolve
|
|
// asynchronously into this spy, cannot make the check flaky.
|
|
const tokenCalls = fetchSpy.mock.calls.filter(
|
|
([input]) =>
|
|
(typeof input === "string" ? input : (input as Request).url) ===
|
|
WITHINGS_TOKEN_URL,
|
|
);
|
|
expect(tokenCalls).toHaveLength(0);
|
|
|
|
// The handler stamps out the expired row so a clock-skewed replay
|
|
// can't slip through.
|
|
const lingering = await prisma.withingsOAuthState.findUnique({
|
|
where: { nonce },
|
|
});
|
|
expect(lingering).toBeNull();
|
|
});
|
|
|
|
it("callback rejects a row whose userId does not match the session, deletes it, and writes no connection", async () => {
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
|
|
|
// Seed a second user + a ledger row that points to *them*. The
|
|
// callback runs under TEST_USER_ID's session, so the row's userId
|
|
// mismatch must trip the rejection branch.
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: "user-other-flow",
|
|
username: "other-user",
|
|
email: "other@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
const nonce = "cross-user-noncexxxxxx";
|
|
await prisma.withingsOAuthState.create({
|
|
data: {
|
|
nonce,
|
|
userId: "user-other-flow",
|
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
|
},
|
|
});
|
|
|
|
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
|
const req = new NextRequest(
|
|
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
|
},
|
|
},
|
|
);
|
|
const res = await callback(req);
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.get("location")).toContain(
|
|
"withings=error&reason=cross_user",
|
|
);
|
|
// No token-endpoint call — short-circuited before exchangeCode fires.
|
|
// (Unrelated transports may still hit `fetch` for wide-event shipping;
|
|
// we narrow to the Withings token URL the same way the replay leg
|
|
// above does so the assertion stays meaningful without coupling to
|
|
// logging-transport details.)
|
|
const crossUserTokenCalls = fetchSpy.mock.calls.filter(
|
|
([input]) =>
|
|
(typeof input === "string" ? input : (input as Request).url) ===
|
|
WITHINGS_TOKEN_URL,
|
|
);
|
|
expect(crossUserTokenCalls).toHaveLength(0);
|
|
|
|
// The row is stamped out — single-use + cross-user mismatch is a
|
|
// CSRF signal, not a recoverable state, so we don't leave the
|
|
// row around for a retry.
|
|
const lingering = await prisma.withingsOAuthState.findUnique({
|
|
where: { nonce },
|
|
});
|
|
expect(lingering).toBeNull();
|
|
|
|
// No connection landed under EITHER user.
|
|
expect(
|
|
await prisma.withingsConnection.findUnique({
|
|
where: { userId: TEST_USER_ID },
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
await prisma.withingsConnection.findUnique({
|
|
where: { userId: "user-other-flow" },
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|