Merge pull request #1303 from MBombeck/release/v1.46.4

v1.46.4: Google Health connects on every profile of an instance
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-30 13:51:10 +01:00
commit 0462c23841
14 files changed
+415 -30

No files matched your search

+13
View File
@@ -1,5 +1,18 @@
# Changelog
## [1.46.4] — 2026-10-30
### Fixed
- **Google Health connects on every profile of an instance again.** The
connect stored the same placeholder as the Google account's id for every
account, so since 1.44.0 a second profile on the same instance was
turned away with "This Google account is already connected to another
HealthLog profile", also when it only reconnected. The connect now
stores the account's real id. An update clears the stored placeholder,
and a profile that was turned away connects with its next attempt.
Instances with one Google Health connection were not affected.
## [1.46.3] — 2026-10-29
### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.46.3
version: 1.46.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.46.3",
"version": "1.46.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,30 @@
-- Google Health user id repair.
--
-- `google_health_connections.google_user_id` held the last path segment of
-- the profile's resource name `users/{user}/profile`, which is the constant
-- `profile` for every account. Since the one-account-per-record check every
-- connection therefore looked like the same Google account, and a second
-- record on the instance could not connect or reconnect (`already_linked`).
--
-- The column becomes nullable (null = id unknown, which the check treats as
-- no evidence of a second holder) and the placeholder values are cleared. The
-- next connect of each record stores the real id. The table holds one row per
-- connected record, so the update is small; the lock wait is bounded.
--
-- Reversibility: the cleared values were the same constant on every row and
-- identified nothing; restoring NOT NULL needs a value on every row first.
--
-- Both statements are repeatable, so a run stopped by the lock timeout is
-- safe to retry.
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
ALTER TABLE "google_health_connections"
ALTER COLUMN "google_user_id" DROP NOT NULL;
UPDATE "google_health_connections"
SET "google_user_id" = NULL
WHERE "google_user_id" IN ('profile', 'me', '');
END
$migration$;
+1 -1
View File
@@ -4614,7 +4614,7 @@ model FitbitOAuthState {
model GoogleHealthConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
googleUserId String @map("google_user_id") // Google external account id
googleUserId String? @map("google_user_id") // Google Health user id; null when the connect could not read it
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.46.3";
/* @sw-version-fallback */ "v1.46.4";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+2 -5
View File
@@ -9,10 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
fetchProfile,
resolveGoogleHealthUserId,
} from "@/lib/google-health/client";
import { fetchProfile, accountIdFor } from "@/lib/google-health/client";
import { googleHealthOAuth } from "@/lib/google-health/oauth";
import { getGoogleHealthScopeString } from "@/lib/google-health/oauth";
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
@@ -179,7 +176,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const profile = await fetchProfile(tokens.access_token);
const googleUserId = resolveGoogleHealthUserId(profile);
const googleUserId = await accountIdFor(profile, tokens.access_token);
// One Google account, one record.
if (
await accountLinkedElsewhere(
@@ -1,10 +1,19 @@
{
"resolveGoogleHealthUserId": [
{
"profile": { "name": "users/1234567890/profile" },
"userId": "1234567890"
},
{ "profile": { "name": "users/me/profile" }, "userId": null },
{
"profile": { "name": "users/me/profile", "id": "id-0001" },
"userId": "id-0001"
},
{ "profile": { "name": "users/1234567890" }, "userId": "1234567890" },
{ "profile": { "name": "users/", "id": "id-0002" }, "userId": "id-0002" },
{ "profile": { "name": "plain-0003" }, "userId": "plain-0003" },
{ "profile": { "name": "plain-0003" }, "userId": null },
{ "profile": { "id": "id-0004" }, "userId": "id-0004" },
{ "profile": {}, "userId": "me" }
{ "profile": {}, "userId": null }
],
"formatCivilBound": [
{
@@ -337,6 +337,9 @@ describe("Google Health mapper goldens", () => {
];
const output = {
resolveGoogleHealthUserId: [
{ name: "users/1234567890/profile" },
{ name: "users/me/profile" },
{ name: "users/me/profile", id: "id-0001" },
{ name: "users/1234567890" },
{ name: "users/", id: "id-0002" },
{ name: "plain-0003" },
+62 -18
View File
@@ -34,20 +34,15 @@ import {
import { GOOGLE_HEALTH_API_BASE } from "./request";
/**
* The Google Health profile carries the external user id used as the
* connection's `externalUserId`. A single GET, not a paginated collection.
* The Google Health profile. Its resource `name` is `users/{user}/profile`,
* where `{user}` is the Google Health user id, or the literal `me` when the
* profile was read through the `users/me` alias.
* Docs: https://developers.google.com/health/reference/rest/v4/Profile
*/
export interface GoogleHealthProfile {
/**
* External user identifier. Google returns `me`-relative profile data; the
* stable id is surfaced under `name` (a `users/{id}` resource name) or `id`
* depending on the API surface — both are captured here and resolved at the
* call site. Re-verify the exact field against a live account at build.
*/
name?: string;
id?: string;
}
export async function fetchProfile(
accessToken: string,
): Promise<GoogleHealthProfile> {
@@ -89,19 +84,68 @@ export async function fetchProfile(
}
/**
* Resolve the stable external user id from a Google Health profile. The
* `users/{id}` resource name is the canonical anchor; fall back to a bare `id`
* or "me" so the connection always persists a non-empty `externalUserId`.
* The Google Health user id in a profile, or null when it carries none.
*
* The id is the `{user}` segment of `users/{user}/profile`. Until v1.46.4 the
* last path segment was taken, which is the constant `profile` for every
* account: every connection stored the same id, and since the one-account-
* per-record check (v1.44.0) a second profile on the same instance could not
* connect at all. `me` is the alias, not an id, so it resolves to null.
*/
export function resolveGoogleHealthUserId(
profile: GoogleHealthProfile,
): string {
if (profile.name) {
const tail = profile.name.split("/").pop();
if (tail) return tail;
): string | null {
const segment = profile.name?.match(/^users\/([^/]+)/)?.[1];
if (segment && segment !== "me") return segment;
if (profile.id && profile.id !== "me") return profile.id;
return null;
}
/**
* The Google Health user id from `users/me/identity` (`healthUserId`), or
* null when the read fails or carries none. Used when the profile names the
* account only through the `me` alias. Best-effort: an account whose id
* cannot be read still connects, with the id left unknown.
* Docs: https://developers.google.com/health/reference/rest/v4/users/getIdentity
*/
export async function fetchHealthUserId(
accessToken: string,
): Promise<string | null> {
const start = performance.now();
try {
const res = await safeFetch(`${GOOGLE_HEALTH_API_BASE}/users/me/identity`, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
getEvent()?.addExternalCall({
service: "google-health",
method: "fetchIdentity",
duration_ms: Math.round(performance.now() - start),
status: res.status,
});
if (!res.ok) return null;
const json = (await res.json().catch(() => null)) as {
healthUserId?: unknown;
} | null;
const id = json?.healthUserId;
return typeof id === "string" && id !== "" && id !== "me" ? id : null;
} catch {
return null;
}
if (profile.id) return profile.id;
return "me";
}
/**
* The id a connection stores: the profile's, else the identity's, else null.
* An id neither read yields is stored as unknown rather than as a shared
* placeholder, so it never makes two records look like one Google account.
*/
export async function accountIdFor(
profile: GoogleHealthProfile,
accessToken: string,
): Promise<string | null> {
return (
resolveGoogleHealthUserId(profile) ?? (await fetchHealthUserId(accessToken))
);
}
// ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ─────────────────
+1 -1
View File
@@ -119,7 +119,7 @@ export interface GoogleHealthTokenInfo {
accessToken: string;
/** When `accessToken` stops being accepted. */
expiresAt: Date;
connection: { id: string; googleUserId: string };
connection: { id: string; googleUserId: string | null };
}
/**
+1
View File
@@ -0,0 +1 @@
{"schemaVersion":"2","exportedAt":"2026-10-30T12:18:11.506Z","userId":"round-trip-owner","appSettings":null,"accountSettings":{"heightCm":null,"dateOfBirth":null,"gender":null,"hasDiabetes":false,"displayName":null,"fullName":null,"insurerName":null,"insuranceNumber":null,"insuranceNumberEncrypted":null,"insurerIkNumber":null,"lastReportPracticeName":null,"avatarBytes":null,"avatarContentType":null,"avatarUpdatedAt":null,"homeTimezone":null,"homeSince":null,"homeLocationEncrypted":null,"environmentAirQualityEnabled":true,"timezone":"Europe/Berlin","locale":null,"unitPreference":null,"glucoseUnit":null,"timeFormat":"AUTO","dateFormat":"AUTO","modulePreferencesJson":null,"thresholdsJson":null,"healthScoreConfigJson":null,"sourcePriorityJson":null,"dashboardWidgetsJson":null,"insightsLayoutJson":null,"medicationListLayoutJson":null,"moodTagLayoutJson":null,"documentsLayoutJson":null,"moduleListLayoutJson":null,"activityGoalsJson":null,"reportSelectionJson":null,"globalExcludedInjectionSites":[],"healthKitConfigJson":null,"notificationPrefs":null,"moodReminderEnabled":false,"aiProvider":null,"aiModel":null,"aiBaseUrl":null,"aiCompatBaseUrl":null,"aiCompatModel":null,"aiProviderChain":null,"aiResponseTimeoutSeconds":null,"useCentralCodex":false,"insightsPrivacyMode":"aggregated","insightsExcludeMetrics":[],"disableCoach":false,"aiUsageLevel":null,"coachPrefsJson":null,"documentsAutoAiRead":false,"labsLocalOcrEnabled":false,"storeWorkoutRoutes":false,"onboardingCompletedAt":null,"onboardingTourCompleted":false,"onboardingTourProgressJson":null,"disclaimerAcknowledgedAt":null,"disclaimerAcknowledgedVersion":null,"passkeyUpgradeNudgeDismissed":false},"measurements":[{"id":"cmvuxucvc002rs5ov1ur3uqyz","type":"BLOOD_GLUCOSE","value":5.4,"valueMin":null,"valueMax":null,"unit":"mmol/L","measuredAt":"2026-07-03T06:45:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":"FASTING","sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-30T12:18:11.496Z","updatedAt":"2026-10-30T12:18:11.496Z"},{"id":"cmvuxucvd002ss5ovf7xaez2v","type":"SLEEP_DURATION","value":95,"valueMin":null,"valueMax":null,"unit":"min","measuredAt":"2026-07-03T02:00:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deep-sleep","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":"DEEP","rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-30T12:18:11.497Z","updatedAt":"2026-10-30T12:18:11.497Z"},{"id":"cmvuxucrx0001s5ov0tk2vm23","type":"PULSE","value":61,"valueMin":null,"valueMax":null,"unit":"bpm","measuredAt":"2026-07-02T06:30:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deleted-sample","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":4,"deletedAt":"2026-10-20T12:18:11.000Z","createdAt":"2026-10-30T12:18:11.373Z","updatedAt":"2026-10-30T12:18:11.373Z"},{"id":"cmvuxucru0000s5ov5wkic22i","type":"WEIGHT","value":74.2,"valueMin":null,"valueMax":null,"unit":"kg","measuredAt":"2026-07-01T07:00:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-30T12:18:11.370Z","updatedAt":"2026-10-30T12:18:11.370Z"},{"id":"cmvuxucrz0002s5ov9fc69imt","type":"IRREGULAR_RHYTHM_NOTIFICATION","value":1,"valueMin":null,"valueMax":null,"unit":"event","measuredAt":"2026-06-15T14:22:00.000Z","source":"WITHINGS","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":"IRREGULAR","deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-30T12:18:11.375Z","updatedAt":"2026-10-30T12:18:11.375Z"}],"customMedicationCategories":[{"key":"custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10","label":null,"labelEncrypted":"djEuWituNlM0cU9UK1JTVjFvc0VPcWtrWVlST3JoUW90U2pPV0dWSW9GT0RKVkNiOTc2Z095VFRjZzFVQnI1MlBRcWdnPT0=","sortOrder":2,"isActive":true,"createdAt":"2026-10-30T12:18:11.397Z","updatedAt":"2026-10-30T12:18:11.397Z"}],"medications":[{"id":"cmvuxucsf0005s5ovy8leot5p","treatmentClass":"GENERIC","dosesPerUnit":null,"unitsPerDose":"1","notificationsEnabled":true,"pausedAt":null,"snoozedUntil":null,"startsOn":"2026-06-10T00:00:00.000Z","endsOn":"2026-06-16T00:00:00.000Z","oneShot":false,"asNeeded":false,"deliveryForm":"ORAL","trackInjectionSites":false,"allowedInjectionSites":[],"liveActivityEnabled"Line truncated
+72
View File
@@ -0,0 +1,72 @@
{
"release": "1.46.4",
"schemaVersion": "2",
"userId": "round-trip-owner",
"exportedAt": "2026-10-30T12:18:11.506Z",
"sha256": "87876fde30c41749fa12d486350ca9a34260174043a611ba92393e96ee137d94",
"bytes": 41985,
"counts": {
"measurements": 5,
"medications": 1,
"intakeEvents": 1,
"medicationSideEffects": 1,
"medicationPauseEras": 2,
"medicationDoseChanges": 2,
"medicationInventoryItems": 2,
"medicationInventoryEvents": 2,
"reminderPhaseConfigs": 1,
"medicationEfficacyTargets": 1,
"medicationScheduleRevisions": 3,
"moodEntries": 1,
"customMoodTagCategories": 1,
"hiddenMoodTags": 1,
"cycles": 2,
"cycleDayLogs": 1,
"nutrientDays": 1,
"labResults": 1,
"biomarkers": 1,
"illnessEpisodes": 2,
"illnessDayLogs": 1,
"allergies": 1,
"familyHistory": 1,
"workouts": 1,
"documents": 1,
"healthProfile": 1,
"customMetrics": 1,
"healthProfileFactRevisions": 1,
"customMetricEntries": 2,
"correlationPatterns": 1,
"intradayProfiles": 1,
"healthScoreRecords": 1,
"onboardingRecords": 1,
"practitioners": 1,
"encounters": 1,
"encounterLinks": 3,
"customVaccines": 1,
"vaccinations": 1,
"vaccinationLinks": 1,
"measurementReminders": 1,
"measurementReminderEvents": 2,
"coachConversations": 2,
"coachMessages": 3,
"coachConversationDocuments": 1,
"coachFacts": 1,
"coachPlans": 1,
"coachReminders": 1,
"mentalHealthAssessments": 1,
"consentReceipts": 2,
"documentConditionLinks": 1,
"extractedFacts": 1,
"documentImportKeys": 1,
"personalRecords": 3,
"userAchievements": 2,
"environmentContexts": 2,
"environmentTravelLocations": 1,
"dawarichDays": 1,
"ecgRecordings": 1,
"symptomDefinitions": 2,
"symptomEvents": 3,
"lifeEvents": 2,
"relationLinks": 5
}
}
@@ -0,0 +1,216 @@
/**
* Two records on one instance connect two different Google accounts, against
* real Postgres and the real callback route.
*
* The id a connection stores is the `{user}` segment of the profile's
* resource name `users/{user}/profile`. Until v1.46.4 the last segment was
* 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.
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
process.env.API_TOKEN_HMAC_KEY ??=
"integration-hmac-key-0123456789abcdef0123456789";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/boss-ready", () => ({
enqueueWhenBossReady: vi.fn().mockResolvedValue("skipped"),
}));
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";
const FIRST = "gh-first";
const SECOND = "gh-second";
function urlOf(input: unknown): string {
return typeof input === "string" ? input : (input as Request).url;
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
/** Google as one account sees it: its profile and identity. */
function googleAs(
healthUserId: string,
opts: { profileName?: string; identityStatus?: number } = {},
) {
vi.mocked(globalThis.fetch).mockImplementation(async (input) => {
const url = urlOf(input);
if (url === TOKEN_URL) {
return json({
access_token: `access-${healthUserId}`,
refresh_token: `refresh-${healthUserId}`,
expires_in: 3600,
});
}
if (url === PROFILE_URL) {
return json({
name: opts.profileName ?? `users/${healthUserId}/profile`,
age: 40,
});
}
if (url === IDENTITY_URL) {
if (opts.identityStatus && opts.identityStatus !== 200) {
return json(
{ error: { code: opts.identityStatus } },
opts.identityStatus,
);
}
return json({
name: "users/me/identity",
healthUserId,
legacyUserId: "LEGACY",
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
}
async function connect(userId: string): Promise<string | null> {
const prisma = getPrismaClient();
const session = await prisma.session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
const nonce = `nonce-${userId}-${Math.random().toString(36).slice(2)}`;
await prisma.googleHealthOAuthState.create({
data: {
nonce,
userId,
codeVerifier: "verifier",
expiresAt: new Date(Date.now() + 60_000),
},
});
const { GET } = await import("@/app/api/google-health/callback/route");
const params = new URLSearchParams({ code: "code", state: nonce });
const res = await GET(
new NextRequest(`http://localhost/api/google-health/callback?${params}`, {
method: "GET",
headers: { cookie: `google_health_state=${nonce}` },
}),
);
return res.headers.get("location");
}
async function storedId(userId: string) {
const row = await getPrismaClient().googleHealthConnection.findUnique({
where: { userId },
select: { googleUserId: true },
});
return row?.googleUserId;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
const { encrypt } = await import("@/lib/crypto");
for (const id of [FIRST, SECOND]) {
await getPrismaClient().user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
role: "USER",
googleHealthClientIdEncrypted: encrypt("client-id"),
googleHealthClientSecretEncrypted: encrypt("client-secret"),
},
});
}
vi.spyOn(globalThis, "fetch");
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Google Health account id", () => {
it("connects two records with two Google accounts and stores each id", async () => {
googleAs("111");
expect(await connect(FIRST)).toContain("googleHealth=connected");
googleAs("222");
expect(await connect(SECOND)).toContain("googleHealth=connected");
expect(await storedId(FIRST)).toBe("111");
expect(await storedId(SECOND)).toBe("222");
});
it("still refuses the same Google account on a second record", async () => {
googleAs("111");
await connect(FIRST);
const location = await connect(SECOND);
expect(location).toContain("reason=already_linked");
expect(await storedId(SECOND)).toBeUndefined();
});
it("reconnects a record whose stored id is the old placeholder, next to another one", async () => {
const { encrypt } = await import("@/lib/crypto");
// The state the repair migration leaves: ids cleared to unknown.
for (const userId of [FIRST, SECOND]) {
await getPrismaClient().googleHealthConnection.create({
data: {
userId,
googleUserId: null,
accessToken: encrypt("old"),
refreshToken: encrypt("old"),
tokenExpiresAt: new Date(),
needsReauth: true,
},
});
}
googleAs("222");
expect(await connect(SECOND)).toContain("googleHealth=connected");
expect(await storedId(SECOND)).toBe("222");
});
it("reads the id from the identity when the profile answers through the me alias", async () => {
googleAs("333", { profileName: "users/me/profile" });
expect(await connect(FIRST)).toContain("googleHealth=connected");
expect(await storedId(FIRST)).toBe("333");
});
it("connects with the id unknown when neither read names the account", async () => {
googleAs("444", { profileName: "users/me/profile", identityStatus: 403 });
expect(await connect(FIRST)).toContain("googleHealth=connected");
expect(await storedId(FIRST)).toBeNull();
googleAs("555", { profileName: "users/me/profile", identityStatus: 403 });
expect(await connect(SECOND)).toContain("googleHealth=connected");
expect(await storedId(SECOND)).toBeNull();
});
});