diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts new file mode 100644 index 000000000..86cb77d27 --- /dev/null +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -0,0 +1,189 @@ +/** + * v1.32.36 — pair guard over the account payload (`GET /api/auth/me`). + * + * The recurring failure this exists to prevent: a two-ended feature whose ends + * ship in different releases. The column lands with the schema change, the + * payload field lands with the API change, and the client that was supposed to + * read it is "the follow-up". Nothing in the gate notices, because every other + * guard proves ONE end — typecheck proves the export exists, knip proves it is + * imported, the OpenAPI check proves the schema matches. None of them prove the + * pair. + * + * `/api/auth/me` is the preferences envelope: it rides every app boot and is + * where user-facing preference fields surface. This test reads the field names + * straight out of the route's response literal and asserts each one is read by + * at least one client module. `lastReportPracticeName` shipped as column + + * doc contract + payload field and sat NULL with no reader for several + * releases; this guard would have failed on the commit that introduced it, and + * the allowlist entry needed to silence it would have had no honest reason to + * write. + * + * ## What it deliberately does not prove + * + * - It proves a field is READ somewhere, not that it is rendered correctly, nor + * that every surface that should read it does. That is what the + * unit-preference display guard does for its own narrower question. + * - The matcher is textual (property access or destructuring position), so a + * field whose name collides with an unrelated local reads as consumed. Every + * such name in this payload (`id`, `email`, `role`, `gender`, `timezone`, + * `modules`) is consumed ubiquitously anyway, so the over-acceptance costs + * nothing here. It would cost something on a payload full of generic names, + * which is why this is a per-envelope guard and not a general sweep. + * - It says nothing about iOS. A field consumed only by the native client must + * be allowlisted with that reason written down, and the reason is an + * assumption about another repository until an audit crosses over. + * + * Mutation check: append a field to the response literal in + * `src/app/api/auth/me/route.ts` and this goes red; delete the practice-name + * read in `health-record-export-panel.tsx` and it goes red too. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +const SRC = join(process.cwd(), "src"); +const ME_ROUTE = join(SRC, "app", "api", "auth", "me", "route.ts"); + +/** + * Where a client consumer may live. `src/app/api/**` is excluded — the server + * writing the field is the other end of the pair, not a consumer of it. + */ +const CONSUMER_ROOTS = [ + join(SRC, "components"), + join(SRC, "hooks"), + join(SRC, "app"), +]; + +const SKIP_DIRS = new Set([ + "__tests__", + "__mocks__", + "node_modules", + ".next", + "generated", +]); + +/** + * `src/hooks/use-auth.ts` declares the `AuthUser` transport type. Naming a + * field in a type declaration is not consuming it — that is precisely the + * shape `lastReportPracticeName` had while it was dead — so the declaration + * file is not a consumer. + */ +const NOT_A_CONSUMER = new Set([ + join(SRC, "hooks", "use-auth.ts"), + join(SRC, "app", "api", "auth", "me", "route.ts"), +]); + +/** + * Fields with no client reader, each with the reason it is nonetheless + * correct for the payload to carry it. An entry here is a claim; write one + * only when the claim is true and checkable. + */ +const NO_CLIENT_CONSUMER: Record = { + insurerIkNumber: + "No web surface reads or writes it — the account form offers the insurer name and the " + + "insurance number, not the institution number. The native client does consume it: it " + + "builds the FHIR Coverage payor from insurerName-or-IK, per " + + ".planning/ios-coord/v0.11-ios-to-server-kvnr-only-coverage.md. Server-side the stored " + + "value feeds the doctor-report cover and the Coverage resource in the health-record " + + "export. The missing web field is a real gap, deliberately not closed in a guard-only " + + "release; this entry is what keeps it visible.", +}; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + if (statSync(p).isDirectory()) { + // The API tree is the producing end, never the consuming one. + if (p === join(SRC, "app", "api")) continue; + walk(p, out); + } else if ( + (name.endsWith(".ts") || name.endsWith(".tsx")) && + !NOT_A_CONSUMER.has(p) && + !name.endsWith(".test.ts") && + !name.endsWith(".test.tsx") + ) { + out.push(p); + } + } + return out; +} + +/** Top-level keys of the `apiSuccess({ … })` literal the route returns. */ +function payloadFields(source: string): string[] { + const start = source.indexOf("return apiSuccess({"); + expect(start).toBeGreaterThan(-1); + const body = source.slice(start); + const fields: string[] = []; + for (const m of body.matchAll(/^ {4}([a-zA-Z_$][\w$]*)\s*[:,]/gm)) { + fields.push(m[1]); + } + return [...new Set(fields)]; +} + +/** + * Comments are stripped before matching. A doc comment naming a field is a + * claim about a consumer, not a consumer — `lastReportPracticeName` had one of + * those for its whole dead life, and counting it would make this guard agree + * with the very prose that was wrong. + */ +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); +} + +/** + * A property access (`user.glucoseUnit`) or a destructuring / object position + * (`{ glucoseUnit }`, `{ glucoseUnit: unit }`). + */ +function readsField(text: string, field: string): boolean { + const re = new RegExp( + `\\.\\s*${field}(?![\\w$])|[{,]\\s*${field}\\s*[,}:=]`, + "m", + ); + return re.test(text); +} + +describe("account payload consumer guard", () => { + const source = readFileSync(ME_ROUTE, "utf8"); + const fields = payloadFields(source); + const consumerFiles = CONSUMER_ROOTS.flatMap((root) => walk(root)); + const consumerText = consumerFiles.map((f) => + stripComments(readFileSync(f, "utf8")), + ); + + it("reads a plausible field set out of the route", () => { + expect(fields.length).toBeGreaterThan(20); + expect(fields).toContain("lastReportPracticeName"); + expect(fields).toContain("moduleAvailability"); + expect(consumerFiles.length).toBeGreaterThan(200); + }); + + it("every field the account endpoint returns has a client consumer", () => { + const unconsumed = fields.filter( + (field) => + !(field in NO_CLIENT_CONSUMER) && + !consumerText.some((text) => readsField(text, field)), + ); + + if (unconsumed.length > 0) { + const report = unconsumed.map((f) => ` ❌ ${f}`).join("\n"); + throw new Error( + `GET /api/auth/me returns ${unconsumed.length} field(s) no client module reads:\n${report}\n\n` + + "Wire the consumer in the same release, or add the field to " + + "NO_CLIENT_CONSUMER with the reason it is correct to ship unread. " + + "A field with no honest reason belongs in neither the payload nor the schema.", + ); + } + }); + + it("the allowlist carries no stale entry", () => { + for (const [field, reason] of Object.entries(NO_CLIENT_CONSUMER)) { + expect( + fields, + `${field} is allowlisted but no longer returned`, + ).toContain(field); + expect(reason.length).toBeGreaterThan(20); + } + }); +}); diff --git a/src/__tests__/measurement-backup-completeness.test.ts b/src/__tests__/measurement-backup-completeness.test.ts new file mode 100644 index 000000000..6efa542fa --- /dev/null +++ b/src/__tests__/measurement-backup-completeness.test.ts @@ -0,0 +1,164 @@ +/** + * v1.32.36 — pair guard binding `prisma/schema.prisma`'s `Measurement` model to + * the disaster-recovery backup select. + * + * The failure it exists to catch: a column is added to the model in one release + * and the backup select is "the follow-up". Nothing fails — the backup keeps + * building, the restore keeps parsing (the payload schema is `.passthrough()`), + * and the column simply comes back NULL on every restored row. That is how the + * aggregation provenance was silently dropped from every disaster-recovery + * backup between the release that added it and the one that noticed. + * + * Both ends of this comparison are literals — a Prisma model block and a named + * select object — so the matcher is exact. No grep heuristics, no fuzzy name + * matching, nothing that can quietly over-accept. That soundness is the reason + * this guard is worth having as a gate while the broader "does any column have + * a reader" question is only worth a release-battery script. + * + * Scoped to `Measurement` on purpose. Extend it model by model as each becomes + * backup-relevant; converting every model at once buys exclusion-list churn + * rather than safety. + * + * Mutation check: comment out `sleepStage: true` in `MEASUREMENT_BACKUP_SELECT` + * and this goes red naming that column; add a scalar to the model without + * adding it to the select and it goes red naming the new one. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +const ROOT = process.cwd(); +const SCHEMA = join(ROOT, "prisma", "schema.prisma"); +const BACKUP_BUILDER = join( + ROOT, + "src", + "lib", + "export", + "full-backup-payload.ts", +); + +/** + * Columns the backup is correct to omit, each with the reason. + * - `userId`: the backup is user-scoped. The owner is implicit in the query + * and re-derived from the restoring account; carrying it would let a + * payload claim a different owner. + */ +const EXCLUDED_COLUMNS: Record = { + userId: "user-scoped backup; owner is re-derived from the restoring account", +}; + +function modelBlock(schema: string, model: string): string { + const start = schema.indexOf(`model ${model} {`); + expect(start, `model ${model} not found`).toBeGreaterThan(-1); + const end = schema.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + return schema.slice(start, end); +} + +/** Names of every `model` / `type` block, so relation fields can be told apart. */ +function declaredModels(schema: string): Set { + const out = new Set(); + for (const m of schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)) { + out.add(m[1]); + } + return out; +} + +/** + * Scalar (column-backed) fields of a model block: everything that is not a + * relation to another model. Enums and native scalars both map to columns and + * therefore both belong in a backup. + */ +function scalarFields(block: string, models: Set): string[] { + const fields: string[] = []; + for (const rawLine of block.split("\n").slice(1)) { + const line = rawLine.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/.exec(line); + if (!m) continue; + const [, name, type, isList] = m; + if (isList) continue; + if (models.has(type)) continue; + if (line.includes("@relation")) continue; + fields.push(name); + } + return fields; +} + +/** Keys of the named `MEASUREMENT_BACKUP_SELECT` literal. */ +function selectFields(source: string): string[] { + const start = source.indexOf("const MEASUREMENT_BACKUP_SELECT = {"); + expect(start).toBeGreaterThan(-1); + const end = source.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + const body = source.slice(start, end); + return [...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*): true,$/gm)].map( + (m) => m[1], + ); +} + +/** + * Fields either serialization arm actually emits. The two arms share one + * restorable wire format, so this is a union and therefore a weaker claim than + * the select comparison: it catches a column that is selected and then written + * to neither payload, not a column carried by only one arm. + */ +function serialisedFields(source: string): Set { + return new Set( + [...source.matchAll(/([a-zA-Z_$][\w$]*): measurement\./g)].map((m) => m[1]), + ); +} + +describe("measurement backup completeness", () => { + const schema = readFileSync(SCHEMA, "utf8"); + const source = readFileSync(BACKUP_BUILDER, "utf8"); + const models = declaredModels(schema); + const columns = scalarFields(modelBlock(schema, "Measurement"), models); + const selected = selectFields(source); + + it("parses both ends into a plausible shape", () => { + expect(models.size).toBeGreaterThan(50); + expect(columns.length).toBeGreaterThan(15); + expect(columns).toContain("aggregationProvenance"); + expect(columns).not.toContain("user"); + expect(columns).not.toContain("ecgRecordings"); + expect(selected.length).toBeGreaterThan(15); + }); + + it("the backup select covers every Measurement column", () => { + const missing = columns.filter( + (c) => !selected.includes(c) && !(c in EXCLUDED_COLUMNS), + ); + + if (missing.length > 0) { + const report = missing.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `MEASUREMENT_BACKUP_SELECT omits ${missing.length} column(s) of the Measurement model:\n${report}\n\n` + + "A disaster-recovery restore brings these back NULL. Add each to the " + + "select and to both serialization arms, extend the backup validation " + + "schema and the restore create — or add it to EXCLUDED_COLUMNS with " + + "the reason the backup is correct to drop it.", + ); + } + }); + + it("every selected column reaches a serialized payload", () => { + const emitted = serialisedFields(source); + const dropped = selected.filter((c) => !emitted.has(c)); + expect( + dropped, + `selected but serialized by neither arm: ${dropped}`, + ).toHaveLength(0); + }); + + it("the exclusion list carries no stale entry", () => { + for (const [column, reason] of Object.entries(EXCLUDED_COLUMNS)) { + expect( + columns, + `${column} is excluded but is no longer a Measurement column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index e0c813eb5..835dade03 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -11,7 +11,7 @@ * artefact); an admin restore re-encrypts them on re-insert. */ import { Buffer } from "node:buffer"; -import type { PrismaClient } from "@/generated/prisma/client"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; import { iterateMeasurementPages } from "@/lib/export/paged-measurements"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; @@ -41,6 +41,40 @@ export interface FullBackupOptions { purpose?: "portable-export" | "disaster-recovery"; exportedAt?: Date; } +/** + * Every restorable `Measurement` scalar. `userId` is deliberately absent — the + * backup is user-scoped, so the owner is implicit in the query and restored + * from the target account rather than the payload. + * + * Named rather than inlined so `measurement-backup-completeness.test.ts` can + * compare it field-by-field against the model in `prisma/schema.prisma`. A + * column added to the model and forgotten here is how the aggregation + * provenance went missing from disaster-recovery backups for several releases. + */ +const MEASUREMENT_BACKUP_SELECT = { + id: true, + type: true, + value: true, + valueMin: true, + valueMax: true, + unit: true, + measuredAt: true, + source: true, + notes: true, + notesEncrypted: true, + externalId: true, + externalSourceVersion: true, + aggregationProvenance: true, + glucoseContext: true, + sleepStage: true, + rhythmClassification: true, + deviceType: true, + syncVersion: true, + deletedAt: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.MeasurementSelect; + /** * Build the canonical full-backup payload for `userId`. Portable exports omit * tombstones and document ciphertext; disaster-recovery payloads preserve @@ -70,29 +104,7 @@ export async function buildFullBackupPayload( const pages = iterateMeasurementPages( prisma, disasterRecovery ? { userId } : { userId, deletedAt: null }, - { - id: true, - type: true, - value: true, - valueMin: true, - valueMax: true, - unit: true, - measuredAt: true, - source: true, - notes: true, - notesEncrypted: true, - externalId: true, - externalSourceVersion: true, - aggregationProvenance: true, - glucoseContext: true, - sleepStage: true, - rhythmClassification: true, - deviceType: true, - syncVersion: true, - deletedAt: true, - createdAt: true, - updatedAt: true, - }, + MEASUREMENT_BACKUP_SELECT, ); for await (const page of pages) { for (const measurement of page) {