From 5306ba7614f5846afcc17fcd78acaa9b417312d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 31 Oct 2026 09:34:25 +0100 Subject: [PATCH] fix(google-health): read the account id back for connections that lost it Migration 0491 cleared the shared "profile" placeholder to null. Those connections sync normally, but the one-account-per-record check cannot see them, so the same Google account could still be connected to a second record until the first one reconnected. After a clean sync cycle, a connection without an id now reads it the way the callback does (profile, then identity) and stores it, guarded on the id still being null so a reconnect landing meanwhile wins. An id another record already holds is not written, and a failed read leaves the id for the next cycle without touching the cycle's verdict. The outcome is on the wide event as googleHealth.accountId.heal. --- src/lib/google-health/account-id-heal.ts | 72 ++++++++++ src/lib/google-health/sync.ts | 4 +- .../google-health-account-id.test.ts | 123 ++++++++++++++++++ 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/lib/google-health/account-id-heal.ts diff --git a/src/lib/google-health/account-id-heal.ts b/src/lib/google-health/account-id-heal.ts new file mode 100644 index 000000000..bbad106ca --- /dev/null +++ b/src/lib/google-health/account-id-heal.ts @@ -0,0 +1,72 @@ +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { accountLinkedElsewhere } from "@/lib/integrations/oauth-kit/account-link"; +import { accountIdFor, fetchProfile } from "./client"; +import { getValidToken, runWithGoogleHealthSyncCycle } from "./sync-core"; + +/** How a cycle's look at a connection without an account id ended. */ +export type GoogleHealthAccountIdHeal = + "healed" | "unreadable" | "linked_elsewhere" | "failed"; + +/** + * Fill in the Google account id of a connection that has none. + * + * Until v1.46.4 every connection stored the constant `profile` as its id, and + * migration 0491 cleared it to null. Such a connection syncs normally, but the + * one-account-per-record check (`accountLinkedElsewhere`) cannot see it, so the + * same Google account could be connected to a second record. This reads the + * id with the connection's own token the way the callback does and stores it, + * which closes that gap without asking anyone to reconnect. + * + * Best-effort and never in the sync's way: a failed read leaves the id null + * for the next cycle, and an id another record already holds is not written + * (two records would then claim one account; the sync itself carries on, and + * the next connect on either record meets the check). The write is guarded on + * the id still being null, so a reconnect landing meanwhile wins. + */ +export async function healGoogleHealthAccountId( + userId: string, + accessToken: string, +): Promise { + let outcome: GoogleHealthAccountIdHeal; + try { + const profile = await fetchProfile(accessToken); + const googleUserId = await accountIdFor(profile, accessToken); + if (!googleUserId) { + outcome = "unreadable"; + } else if ( + await accountLinkedElsewhere("google-health", googleUserId, userId) + ) { + outcome = "linked_elsewhere"; + } else { + await prisma.googleHealthConnection.updateMany({ + where: { userId, googleUserId: null }, + data: { googleUserId }, + }); + outcome = "healed"; + } + } catch (err) { + getEvent()?.addWarning( + `google-health: account id read failed for ${userId}: ${err}`, + ); + outcome = "failed"; + } + annotate({ meta: { "googleHealth.accountId.heal": outcome } }); + return outcome; +} + +/** + * The sync cycle's hook: after a clean cycle, a connection from before + * v1.46.4 (no account id since migration 0491) reads its id back. A + * connection that has one is left alone, so the profile is read once. + */ +export async function healUnknownGoogleHealthAccountId( + userId: string, + googleUserId: string | null, +): Promise { + if (googleUserId !== null) return; + const token = await runWithGoogleHealthSyncCycle(() => getValidToken(userId)); + if (token.result) { + await healGoogleHealthAccountId(userId, token.result.accessToken); + } +} diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index 02676d189..611f234a9 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -15,6 +15,7 @@ import { syncUserMetrics } from "./sync-metrics"; import { syncUserSleep } from "./sync-sleep"; import { syncUserWorkout } from "./sync-workout"; import { withGoogleHealthSyncLock } from "./sync-lock"; +import { healUnknownGoogleHealthAccountId } from "./account-id-heal"; import { GOOGLE_HEALTH_INTEGRATION_KEY, getValidToken, @@ -235,7 +236,7 @@ async function runGoogleHealthSyncCycle( const cycleStartedAt = new Date(); const connection = await prisma.googleHealthConnection.findUnique({ where: { userId }, - select: { lastSyncedAt: true }, + select: { lastSyncedAt: true, googleUserId: true }, }); if (!connection) { return { state: "failed", imported: 0, failed: true, resources: [] }; @@ -389,6 +390,7 @@ async function runGoogleHealthSyncCycle( if (!failed) { await markSynced(userId, cycleStartedAt); await recordSyncSuccess(userId, GOOGLE_HEALTH_INTEGRATION_KEY); + await healUnknownGoogleHealthAccountId(userId, connection.googleUserId); } // Background-sync posture (mirrors the Fitbit tail): mark the per-user diff --git a/tests/integration/google-health-account-id.test.ts b/tests/integration/google-health-account-id.test.ts index 5c0ec2cd1..07bfd9332 100644 --- a/tests/integration/google-health-account-id.test.ts +++ b/tests/integration/google-health-account-id.test.ts @@ -7,6 +7,10 @@ * stored, the constant `profile`, so every connection looked like the same * Google account and the one-account-per-record check refused the second * record on the instance with `already_linked`, also on a reconnect. + * + * Migration 0491 cleared those placeholders to null. A connection left with + * no id syncs, but the check cannot see it; a clean sync cycle reads the id + * back with the connection's own token and stores it. */ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -48,6 +52,22 @@ vi.mock("@/lib/jobs/boss-ready", () => ({ enqueueWhenBossReady: vi.fn().mockResolvedValue("skipped"), })); +// The sync cycle's four resources stand in as one written row each, so a +// cycle is clean and reaches the account-id read; their own wire is covered +// elsewhere. +vi.mock("@/lib/google-health/sync-workout", () => ({ + syncUserWorkout: vi.fn(async () => 1), +})); +vi.mock("@/lib/google-health/sync-sleep", () => ({ + syncUserSleep: vi.fn(async () => 1), +})); +vi.mock("@/lib/google-health/sync-activity", () => ({ + syncUserActivity: vi.fn(async () => 1), +})); +vi.mock("@/lib/google-health/sync-metrics", () => ({ + syncUserMetrics: vi.fn(async () => 1), +})); + const TOKEN_URL = "https://oauth2.googleapis.com/token"; const PROFILE_URL = "https://health.googleapis.com/v4/users/me/profile"; const IDENTITY_URL = "https://health.googleapis.com/v4/users/me/identity"; @@ -214,3 +234,106 @@ describe("Google Health account id", () => { expect(await storedId(SECOND)).toBeNull(); }); }); + +/** A connection as migration 0491 leaves it: synced, token valid, id unknown. */ +async function seedUnknownId( + userId: string, + googleUserId: string | null = null, +) { + const { encrypt } = await import("@/lib/crypto"); + await getPrismaClient().googleHealthConnection.create({ + data: { + userId, + googleUserId, + accessToken: encrypt(`access-${userId}`), + refreshToken: encrypt(`refresh-${userId}`), + tokenExpiresAt: new Date(Date.now() + 3_600_000), + needsReauth: false, + }, + }); +} + +function profileReads(): number { + return vi + .mocked(globalThis.fetch) + .mock.calls.filter(([input]) => urlOf(input) === PROFILE_URL).length; +} + +describe("Google Health account id after the placeholder repair", () => { + it("reads the id back on a clean sync, so the same account is refused elsewhere", async () => { + await seedUnknownId(FIRST); + googleAs("111"); + const { syncUserGoogleHealth } = await import("@/lib/google-health/sync"); + const result = await syncUserGoogleHealth(FIRST); + expect(result.failed).toBe(false); + expect(await storedId(FIRST)).toBe("111"); + + expect(await connect(SECOND)).toContain("reason=already_linked"); + expect(await storedId(SECOND)).toBeUndefined(); + }); + + it("does not read the profile again once the id is known", async () => { + await seedUnknownId(FIRST, "111"); + googleAs("111"); + const { syncUserGoogleHealth } = await import("@/lib/google-health/sync"); + await syncUserGoogleHealth(FIRST); + expect(profileReads()).toBe(0); + expect(await storedId(FIRST)).toBe("111"); + }); + + it("leaves the id unknown when another record already holds the account", async () => { + await seedUnknownId(FIRST); + await seedUnknownId(SECOND, "111"); + googleAs("111"); + const { healGoogleHealthAccountId } = + await import("@/lib/google-health/account-id-heal"); + expect(await healGoogleHealthAccountId(FIRST, "access-gh-first")).toBe( + "linked_elsewhere", + ); + expect(await storedId(FIRST)).toBeNull(); + expect(await storedId(SECOND)).toBe("111"); + }); + + it("keeps an id a reconnect stored while the read was under way", async () => { + await seedUnknownId(FIRST); + vi.mocked(globalThis.fetch).mockImplementation(async (input) => { + const url = urlOf(input); + if (url === PROFILE_URL) { + // The callback lands between the profile read and the write. + await getPrismaClient().googleHealthConnection.update({ + where: { userId: FIRST }, + data: { googleUserId: "999" }, + }); + return json({ name: "users/111/profile" }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + const { healGoogleHealthAccountId } = + await import("@/lib/google-health/account-id-heal"); + expect(await healGoogleHealthAccountId(FIRST, "access-gh-first")).toBe( + "healed", + ); + expect(await storedId(FIRST)).toBe("999"); + }); + + it("stays unknown, and the sync stays clean, when Google names no account", async () => { + await seedUnknownId(FIRST); + googleAs("444", { profileName: "users/me/profile", identityStatus: 403 }); + const { syncUserGoogleHealth } = await import("@/lib/google-health/sync"); + const result = await syncUserGoogleHealth(FIRST); + expect(result.failed).toBe(false); + expect(await storedId(FIRST)).toBeNull(); + }); + + it("keeps the sync clean when the profile read fails", async () => { + await seedUnknownId(FIRST); + vi.mocked(globalThis.fetch).mockImplementation(async (input) => { + if (urlOf(input) === PROFILE_URL) return json({ error: {} }, 503); + throw new Error(`Unexpected fetch: ${urlOf(input)}`); + }); + const { syncUserGoogleHealth } = await import("@/lib/google-health/sync"); + const result = await syncUserGoogleHealth(FIRST); + expect(result.failed).toBe(false); + expect(await storedId(FIRST)).toBeNull(); + }); +});