diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index 58aed091c..251b5298b 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -175,6 +175,24 @@ describe("GET /api/whoop/callback custom-scheme redirect", () => { expect(connUpsert).not.toHaveBeenCalled(); }); + it("refuses a profile without a user id instead of storing a shared placeholder", async () => { + stateDelete.mockResolvedValue({ + userId: "u1", + expiresAt: new Date(Date.now() + 60_000), + returnScheme: null, + }); + const { fetchProfile } = await import("@/lib/whoop/client"); + vi.mocked(fetchProfile).mockResolvedValueOnce({} as never); + + const res = await GET(makeReq("nonce-noid")); + + expect(res.headers.get("location")).toBe( + "https://app.example/settings/integrations?whoop=error&reason=token", + ); + expect(connFindUnique).not.toHaveBeenCalled(); + expect(connUpsert).not.toHaveBeenCalled(); + }); + it("maps a concurrent WHOOP identity P2002 to the safe owner-conflict redirect", async () => { stateDelete.mockResolvedValue({ userId: "local-user", diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 36da98e8b..e1a511857 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -204,6 +204,15 @@ export const GET = apiHandler(async (request: NextRequest) => { const tokens = await whoopOAuth.exchange(code, creds); const profile = await fetchProfile(tokens.access_token); + // An off-spec profile without `user_id` would store the string + // "undefined" as the account id: every such connection would then look + // like one WHOOP account, and the check below would refuse the second. + if ( + typeof profile.user_id !== "number" || + !Number.isFinite(profile.user_id) + ) { + return outcomeRedirect(returnScheme, "error", "token"); + } const whoopUserId = String(profile.user_id); const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); const owner = await prisma.whoopConnection.findUnique({