mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Garmin Connect now offers a Personal API: a person creates a key for their own account and any client can buy short-lived tokens with it. HealthLog takes that key in a new Garmin card, checks it with Garmin before storing it sealed on the user row, and polls every hour. - Transport: key exchange with a per-process token cache keyed on the key, one re-exchange on a refused token and then reauth_required, the 429 Retry-After carried to the caller, the China host tried when the global one does not know the key, answers read with a byte cap, all egress through safeFetch to the two fixed hosts. - Sync: daily summaries, sleep summaries, HRV, weigh-ins and the activity search, chunked to each resource's documented range, read one after another and stopped at the first rate limit; today's menstrual cycle summary adds logged period days the person has not logged. - Backfill: a resumable walk through the shared admission lane that learns where the history starts from the daily summaries and the activities, waits out Garmin's rate limit and stamps its marker. - Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest token. All four chart on the recovery page and sit on the ladders, units, ranges, labels and report groups like the device-native scores. - Key expiry: learned from a token capped short of a day, else estimated from the validity picked at connect; the card warns a week ahead and asks for a new key once Garmin refuses the old one. - Ledger, health, freshness, watchdog, backup classes, wipe plan, encrypted-column registry, rotation script, OpenAPI and all ten locales carry the new integration. The old "no Garmin connector" note is gone.
220 lines
7.9 KiB
TypeScript
220 lines
7.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
assertRecordSettingsAccess,
|
|
type RecordSettingsAccess,
|
|
} from "@/lib/record-settings/access";
|
|
import {
|
|
classifySettingsDestination,
|
|
isGuardianSettingsWriteAllowed,
|
|
isManageDelegateSettingsDestination,
|
|
SETTINGS_DESTINATION_INVENTORY,
|
|
} from "@/lib/record-settings/classification";
|
|
import { RECORD_CONTENT_SECTIONS } from "@/components/settings/record-settings-section-gate";
|
|
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
|
|
import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations";
|
|
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
|
|
import { recordSettingsKeys } from "@/lib/query-keys";
|
|
|
|
describe("record settings contract", () => {
|
|
it("names the target record in both the DTO and cache key", () => {
|
|
const dto = toRecordSettingsDto({
|
|
id: "record-1",
|
|
name: "Managed profile",
|
|
locale: "de",
|
|
timezone: "Europe/Berlin",
|
|
recordKind: "managed",
|
|
});
|
|
|
|
expect(dto).toEqual({
|
|
record: {
|
|
id: "record-1",
|
|
displayName: "Managed profile",
|
|
locale: "de",
|
|
timezone: "Europe/Berlin",
|
|
kind: "managed",
|
|
},
|
|
});
|
|
expect(recordSettingsKeys.detail("record-1")).toEqual([
|
|
"record-settings",
|
|
"record-1",
|
|
"detail",
|
|
]);
|
|
expect(recordSettingsKeys.recordSettingsIntegrations("record-1")).toEqual([
|
|
"record-settings",
|
|
"record-1",
|
|
"integrations",
|
|
]);
|
|
});
|
|
|
|
it("does not let an adult MANAGE grant use Guardian-only configuration", () => {
|
|
const access: RecordSettingsAccess = {
|
|
actorId: "adult-manager",
|
|
recordId: "managed-record",
|
|
recordKind: "managed",
|
|
relationship: "adult-manager",
|
|
};
|
|
|
|
expect(() => assertRecordSettingsAccess(access, "guardian")).toThrow(
|
|
"Guardian configuration is unavailable for this record",
|
|
);
|
|
});
|
|
|
|
it("classifies every Settings destination and limits Guardian writes", () => {
|
|
expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0);
|
|
expect(classifySettingsDestination("integrations")).toMatchObject({
|
|
kind: "managed-guardian",
|
|
guardianWritable: false,
|
|
});
|
|
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
|
|
// v1.37.19 — "dashboard" left SETTINGS_SECTION_SLUGS (its URL is
|
|
// owned by a permanent redirect to /settings/layout/dashboard), so an
|
|
// unknown destination falls closed to plain unavailable; "layout" is
|
|
// the surviving adult-shared-unavailable representative.
|
|
expect(classifySettingsDestination("dashboard").kind).toBe("unavailable");
|
|
expect(classifySettingsDestination("layout").kind).toBe(
|
|
"adult-shared-unavailable",
|
|
);
|
|
expect(isGuardianSettingsWriteAllowed("account")).toBe(true);
|
|
expect(isGuardianSettingsWriteAllowed("notifications")).toBe(true);
|
|
expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false);
|
|
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
|
|
});
|
|
|
|
/**
|
|
* v1.37.0 — the anamnesis destination, which is the one piece of record
|
|
* CONTENT under `/settings`.
|
|
*
|
|
* `POST /api/allergies` and `POST /api/family-history` have resolved
|
|
* `requireRecordAuth("manage", "profile")` since MANAGE shipped, while the
|
|
* only form that posts to either was classified unavailable — an admitted
|
|
* write with no reachable caller. These three legs are the classification
|
|
* half of closing that; the integration file drives the routes themselves.
|
|
*/
|
|
it("opens the anamnesis destination to everybody the routes already admit", () => {
|
|
expect(classifySettingsDestination("anamnesis")).toEqual({
|
|
kind: "manage-writable",
|
|
guardianWritable: true,
|
|
});
|
|
// The Guardian half. Before this the destination existed and was
|
|
// read-only for the one person the profile exists for.
|
|
expect(isGuardianSettingsWriteAllowed("anamnesis")).toBe(true);
|
|
// The adult-delegate half, and its boundary in the same breath: a MANAGE
|
|
// grant opens the record's health background and nothing else under
|
|
// Settings. Record CONFIGURATION — modules, thresholds, notification
|
|
// routing — stays with the owner even though a Guardian may change it.
|
|
expect(isManageDelegateSettingsDestination("anamnesis")).toBe(true);
|
|
for (const guardianOnly of [
|
|
"account",
|
|
"modules",
|
|
"notifications",
|
|
"thresholds",
|
|
"insights",
|
|
"coach",
|
|
"integrations",
|
|
]) {
|
|
expect(
|
|
isManageDelegateSettingsDestination(guardianOnly),
|
|
guardianOnly,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("gives every record-content destination a page of its own", () => {
|
|
// Both directions, and the first is the one that matters. `manage-writable`
|
|
// is a classification; the gate has to know which PAGE each slug is, or a
|
|
// destination added to the kind would silently inherit the allergy and
|
|
// family-history managers under its own heading. The map is what says
|
|
// which, and this is what stops the two drifting apart — a new
|
|
// manage-writable slug fails here, at the moment it is added, rather than
|
|
// rendering somebody else's forms.
|
|
const classified = Object.keys(SETTINGS_DESTINATION_INVENTORY)
|
|
.filter(isManageDelegateSettingsDestination)
|
|
.sort();
|
|
const rendered = Object.keys(RECORD_CONTENT_SECTIONS).sort();
|
|
|
|
expect(classified).not.toHaveLength(0);
|
|
expect(rendered).toEqual(classified);
|
|
});
|
|
|
|
it("leaves exactly one destination on the manage-writable list", () => {
|
|
// The list is short on purpose and a second entry is a consent decision,
|
|
// not a classification tidy-up. Pinning the count means a destination
|
|
// cannot join it as a side effect of somebody reclassifying a neighbour.
|
|
const manageWritable = Object.entries(SETTINGS_DESTINATION_INVENTORY)
|
|
.filter(([, value]) => value.kind === "manage-writable")
|
|
.map(([slug]) => slug);
|
|
expect(manageWritable).toEqual(["anamnesis"]);
|
|
});
|
|
|
|
it("does not report a synthetic ledger row as a managed connection", () => {
|
|
const connected = {
|
|
withings: false,
|
|
whoop: false,
|
|
fitbit: false,
|
|
nightscout: false,
|
|
polar: false,
|
|
oura: false,
|
|
"google-health": false,
|
|
strava: false,
|
|
dawarich: false,
|
|
garmin: false,
|
|
} as const;
|
|
|
|
expect(
|
|
resolveManagedIntegrationState("connected", connected, "withings"),
|
|
).toBe("disconnected");
|
|
expect(
|
|
resolveManagedIntegrationState(
|
|
"error_reauth",
|
|
{ ...connected, oura: true },
|
|
"oura",
|
|
),
|
|
).toBe("error_reauth");
|
|
});
|
|
|
|
it("reports a real credential with no ledger row as connected", () => {
|
|
// The connection map is the credential: this branch is only reached once
|
|
// it says a pipe exists. `unknown` on top of it means the sync ledger
|
|
// holds no row yet — an hourly provider connected minutes ago — and this
|
|
// projection's `state` is documented as "a pipe exists", not as liveness.
|
|
// Calling it "Not connected" invites a guardian to revoke the grant they
|
|
// just made.
|
|
const connected = {
|
|
withings: true,
|
|
whoop: false,
|
|
fitbit: false,
|
|
nightscout: false,
|
|
polar: false,
|
|
oura: false,
|
|
"google-health": false,
|
|
strava: false,
|
|
dawarich: false,
|
|
garmin: false,
|
|
} as const;
|
|
|
|
expect(
|
|
resolveManagedIntegrationState("unknown", connected, "withings"),
|
|
).toBe("connected");
|
|
// No credential still outranks it.
|
|
expect(resolveManagedIntegrationState("unknown", connected, "oura")).toBe(
|
|
"disconnected",
|
|
);
|
|
});
|
|
|
|
it("rejects a late response for another record in a second tab", () => {
|
|
expect(() =>
|
|
assertRecordSettingsResponseForRecord(
|
|
{ recordId: "managed-record-b" },
|
|
"managed-record-a",
|
|
),
|
|
).toThrow("did not match its key");
|
|
expect(() =>
|
|
assertRecordSettingsResponseForRecord(
|
|
{ recordId: "managed-record-a" },
|
|
"managed-record-a",
|
|
),
|
|
).not.toThrow();
|
|
});
|
|
});
|