fix(whoop): refuse a profile without a user id instead of storing "undefined"

String(profile.user_id) turned a missing id into the literal "undefined",
so every such connection would have looked like one WHOOP account and the
unique account id would have refused the second record. The callback now
treats it like a failed exchange (reason=token), as the Polar callback
already does for a token body without x_user_id.
This commit is contained in:
Marc-André Bombeck committed 2026-10-31 09:34:25 +01:00
1 parent 5306ba7614
commit b42b0d5fee
2 files changed
+27

No files matched your search

@@ -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",
+9
View File
@@ -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({