diff --git a/src/app/api/withings/connect/__tests__/route.test.ts b/src/app/api/withings/connect/__tests__/route.test.ts new file mode 100644 index 000000000..cef1b033d --- /dev/null +++ b/src/app/api/withings/connect/__tests__/route.test.ts @@ -0,0 +1,136 @@ +/** + * v1.4.48 L5 + L14 — GET /api/withings/connect + * + * Pins: + * 1. Happy path: mints a nonce, creates a `WithingsOAuthState` row, + * redirects to the Withings authorize URL with the state cookie + * attached. + * 2. Rate-limit: the 11th call in a 60 s window redirects to + * `/settings/integrations?withings=error&reason=rate_limited` + * and does not call `prisma.withingsOAuthState.create`. + * 3. Row-create failure: a Prisma error inside the try/catch + * redirects to + * `/settings/integrations?withings=error&reason=connect` + * instead of bubbling a 500. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1" }, + session: { id: "s-1" }, + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + withingsOAuthState: { + create: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/withings/credentials", () => ({ + getUserWithingsCredentials: vi.fn(), +})); + +vi.mock("@/lib/withings/client", () => ({ + getAuthorizationUrl: vi.fn( + () => "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub", + ), +})); + +vi.mock("@/lib/withings/oauth-state", () => ({ + WITHINGS_OAUTH_STATE_COOKIE: "withings_oauth_state", + WITHINGS_OAUTH_STATE_TTL_MS: 10 * 60 * 1000, + mintWithingsOAuthStateNonce: vi.fn(() => "nonce-stub"), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ + setError: vi.fn(), + addWarning: vi.fn(), + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getUserWithingsCredentials } from "@/lib/withings/credentials"; + +function connectRequest(): NextRequest { + return new NextRequest("http://localhost/api/withings/connect", { + method: "GET", + }); +} + +beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(prisma.withingsOAuthState.create).mockReset(); + vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue( + {} as never, + ); + vi.mocked(getUserWithingsCredentials).mockResolvedValue({ + clientId: "client-id", + clientSecret: "client-secret", + } as never); +}); + +describe("GET /api/withings/connect", () => { + it("redirects to the Withings authorize URL on the happy path", async () => { + const response = await GET(connectRequest()); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toContain( + "account.withings.com/oauth2_user/authorize2", + ); + expect(prisma.withingsOAuthState.create).toHaveBeenCalledTimes(1); + expect(checkRateLimit).toHaveBeenCalledWith( + "withings:connect:u-1", + 10, + 60_000, + ); + }); + + it("redirects to reason=rate_limited when the 11th call within 60 s is rejected", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const response = await GET(connectRequest()); + + expect(response.status).toBe(307); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("withings=error"); + expect(location).toContain("reason=rate_limited"); + expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled(); + }); + + it("redirects to reason=connect when the row create throws", async () => { + vi.mocked(prisma.withingsOAuthState.create).mockRejectedValueOnce( + new Error("Prisma transient failure"), + ); + + const response = await GET(connectRequest()); + + expect(response.status).toBe(307); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("withings=error"); + expect(location).toContain("reason=connect"); + }); +}); diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts index fd8393327..9ed054552 100644 --- a/src/app/api/withings/connect/route.ts +++ b/src/app/api/withings/connect/route.ts @@ -1,7 +1,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; -import { annotate } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { getAuthorizationUrl } from "@/lib/withings/client"; import { getUserWithingsCredentials } from "@/lib/withings/credentials"; import { @@ -9,7 +10,7 @@ import { WITHINGS_OAUTH_STATE_TTL_MS, mintWithingsOAuthStateNonce, } from "@/lib/withings/oauth-state"; -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; /** * Redirects the user to Withings OAuth authorization page. @@ -21,11 +22,39 @@ import { NextResponse } from "next/server"; * nonce, and the callback resolves the user via the row's `userId` * column. Closes the v1.4.43 security audit L-1 finding where the * user id was recoverable from log entries / network captures. + * + * v1.4.48 L5 + L14 — endpoint is now rate-limited per-user (10 calls + * per 60 s) so a logged-in user with valid creds can't spam ledger + * rows for the full 10-min TTL window. The row-create call is wrapped + * in try/catch; a transient DB glitch now lands on the existing + * `/settings/integrations?withings=error&reason=connect` surface + * instead of bubbling a 500. Both rate-limit and create-failure paths + * redirect rather than returning JSON because the entry point is a + * browser navigation — a 429 envelope would surface as a blank page. */ -export const GET = apiHandler(async () => { + +const CONNECT_RATE_LIMIT = 10; +const CONNECT_WINDOW_MS = 60_000; + +export const GET = apiHandler(async (req: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "withings.connect" } }); + const rl = await checkRateLimit( + `withings:connect:${user.id}`, + CONNECT_RATE_LIMIT, + CONNECT_WINDOW_MS, + ); + if (!rl.allowed) { + annotate({ action: { name: "withings.connect.rate_limited" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?withings=error&reason=rate_limited", + req.url, + ), + ); + } + const creds = await getUserWithingsCredentials(user.id); if (!creds) { return apiError( @@ -35,13 +64,24 @@ export const GET = apiHandler(async () => { } const nonce = mintWithingsOAuthStateNonce(); - await prisma.withingsOAuthState.create({ - data: { - nonce, - userId: user.id, - expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS), - }, - }); + try { + await prisma.withingsOAuthState.create({ + data: { + nonce, + userId: user.id, + expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS), + }, + }); + } catch (err) { + getEvent()?.setError(err); + annotate({ action: { name: "withings.connect.create_failed" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?withings=error&reason=connect", + req.url, + ), + ); + } const url = getAuthorizationUrl(nonce, creds);