Files
HealthLog/src/__tests__/doctor-report-control-gating-guard.test.ts
T
Marc-André Bombeck e2ba86b12f feat(garmin): sync Garmin Connect through the Personal API
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.
2026-11-04 10:14:10 +01:00

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");
}
});
});