mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
eb47f04a66
commit
5306ba7614
3 files changed
+198
-1
No files matched your search
@@ -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<GoogleHealthAccountIdHeal> {
|
||||
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<void> {
|
||||
if (googleUserId !== null) return;
|
||||
const token = await runWithGoogleHealthSyncCycle(() => getValidToken(userId));
|
||||
if (token.result) {
|
||||
await healGoogleHealthAccountId(userId, token.result.accessToken);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user