mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12: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.
147 lines
5.2 KiB
TypeScript
147 lines
5.2 KiB
TypeScript
/**
|
|
* Structural guard: a rendered scope control with no gating path fails the
|
|
* build, and a gating path with no control fails it too.
|
|
*
|
|
* The export panel once shipped twenty data switches, eight of which changed
|
|
* nothing at all — the wire fold read twelve of the keys it accepted and
|
|
* dropped the rest on the floor. Nothing failed; the control simply lied, and
|
|
* it lied for several releases because no test connected "the panel renders
|
|
* this" to "the export honours this".
|
|
*
|
|
* The selection model removed the fold, so the shape that defect lived in is
|
|
* gone. This file is the same connection retargeted at the leaf catalogue,
|
|
* which is now the one thing the picker, the aggregator, the PDF and the share
|
|
* view all read:
|
|
*
|
|
* 1. Every leaf the catalogue declares is reachable in the picker — the
|
|
* picker renders from the catalogue, so the check is that the catalogue
|
|
* places each leaf in exactly one group and the picker renders each group.
|
|
* 2. Flipping exactly one leaf changes the resolved selection. This is the
|
|
* assertion that would have caught all eight dead switches.
|
|
* 3. Every leaf is honoured somewhere downstream: as a measurement type the
|
|
* gate strips, or by name in the aggregator's own body.
|
|
*
|
|
* What it does NOT prove: that the excluded data is actually absent from a
|
|
* rendered artefact. That is behavioural and is covered where the artefacts are
|
|
* built — `src/app/api/export/health-record/__tests__/route.test.ts` opens the
|
|
* real PDF, the real bundle and the real zip. This guard is the cheap
|
|
* structural half that cannot be satisfied by writing a plausible-looking
|
|
* control.
|
|
*
|
|
* Mutation checks are recorded per assertion below.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
ALL_LEAF_IDS,
|
|
MEASUREMENT_LEAF_GROUP,
|
|
REPORT_GROUPS,
|
|
STRUCTURED_LEAF_GROUP,
|
|
isStructuredLeafId,
|
|
leafGroup,
|
|
type ReportLeafId,
|
|
} from "@/lib/report-selection/catalogue";
|
|
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
|
import { stripComments } from "./helpers/source-files";
|
|
|
|
const REPO_ROOT = join(__dirname, "..", "..");
|
|
const COLLECT_PATH = join(
|
|
REPO_ROOT,
|
|
"src",
|
|
"lib",
|
|
"doctor-report",
|
|
"collect.ts",
|
|
);
|
|
const PICKER_PATH = join(
|
|
REPO_ROOT,
|
|
"src",
|
|
"components",
|
|
"settings",
|
|
"report-selection",
|
|
"report-scope-picker.tsx",
|
|
);
|
|
|
|
const collectSource = readFileSync(COLLECT_PATH, "utf8");
|
|
const pickerSource = readFileSync(PICKER_PATH, "utf8");
|
|
|
|
describe("report scope gating — structural guard", () => {
|
|
it("reads a plausible catalogue", () => {
|
|
// Sanity floor: a silently degraded catalogue would pass everything below
|
|
// vacuously.
|
|
expect(ALL_LEAF_IDS.length).toBe(110);
|
|
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(90);
|
|
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
|
|
});
|
|
|
|
it("places every leaf in exactly one group", () => {
|
|
const seen = new Map<ReportLeafId, number>();
|
|
for (const group of REPORT_GROUPS) {
|
|
for (const leaf of group.leaves) {
|
|
seen.set(leaf, (seen.get(leaf) ?? 0) + 1);
|
|
}
|
|
}
|
|
const duplicated = [...seen.entries()].filter(([, n]) => n > 1);
|
|
const missing = ALL_LEAF_IDS.filter((leaf) => !seen.has(leaf));
|
|
expect(duplicated, "a leaf appears in more than one group").toEqual([]);
|
|
expect(missing, "a leaf is in no group and so is unreachable").toEqual([]);
|
|
});
|
|
|
|
it("renders every group the catalogue declares", () => {
|
|
// The picker maps over `REPORT_GROUPS` and splits the fenced tier out by
|
|
// id, so this is the check that the split cannot silently drop a group.
|
|
expect(pickerSource).toContain("REPORT_GROUPS.map");
|
|
expect(pickerSource).toContain("SENSITIVE_GROUP_ID");
|
|
});
|
|
|
|
it("makes flipping any single leaf change the resolved selection", () => {
|
|
const all = selectionFromLeaves(ALL_LEAF_IDS);
|
|
const inert: ReportLeafId[] = [];
|
|
for (const leaf of ALL_LEAF_IDS) {
|
|
const without = selectionFromLeaves(
|
|
ALL_LEAF_IDS.filter((l) => l !== leaf),
|
|
);
|
|
if (
|
|
without.has(leaf) ||
|
|
without.leaves.length !== all.leaves.length - 1
|
|
) {
|
|
inert.push(leaf);
|
|
}
|
|
}
|
|
expect(
|
|
inert,
|
|
"these leaves cannot be individually withheld, so their control would lie",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("has something downstream read every leaf", () => {
|
|
// A leaf is honoured either as a measurement type — the gate strips those
|
|
// by set membership over the whole enum — or by name in the aggregator.
|
|
const body = stripComments(collectSource);
|
|
const unread = ALL_LEAF_IDS.filter(
|
|
(leaf) => isStructuredLeafId(leaf) && !body.includes(`"${leaf}"`),
|
|
);
|
|
expect(
|
|
unread,
|
|
"these structured leaves are never read in the aggregator, so switching " +
|
|
"them off would change nothing that leaves the instance",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("keeps every sensitive leaf in the fenced tier", () => {
|
|
for (const leaf of [
|
|
"MOOD",
|
|
"ANAMNESIS",
|
|
"CYCLE",
|
|
"FAMILY_HISTORY",
|
|
"PHQ9_SCORE",
|
|
"GAD7_SCORE",
|
|
"WHO5_SCORE",
|
|
"SCI_SCORE",
|
|
] as ReportLeafId[]) {
|
|
expect(leafGroup(leaf), `${leaf} must be fenced`).toBe("sensitive");
|
|
}
|
|
});
|
|
});
|