test(guards): bind the account payload and the backup select to their pairs

Every finding behind this work is a two-ended change whose ends shipped in
different releases with nothing holding them together, and every gate the repo
runs proves one end: typecheck proves the export exists, knip proves it is
imported, the OpenAPI check proves the schema matches. None of them notice when
the second end never arrives.

Two questions here can be answered exactly, so they become tests.

The account endpoint rides every app boot and carries the user's preferences.
Its field names are read out of the response literal and each one must be read
by a client module — property access or destructuring, comments stripped,
because a doc comment claiming a consumer is what the remembered practice name
had for its whole dead life. That field would have failed this on the day it
shipped. One field is allowlisted today with its reason written out: the
insurer institution number has no web surface at all, which the entry keeps
visible rather than papering over.

The measurement backup select is now a named constant, compared field by field
against the model in the schema. Both sides are literals, so the comparison is
exact — no name heuristics, nothing that can drift towards accepting. A column
added to the model and forgotten by the backup fails at the commit that adds
it, which is how the aggregation provenance went missing. Scoped to the one
model; extend it as each further model becomes backup-relevant.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 16:24:12 +02:00
1 parent d8a3c08e12
commit 99b8dfd566
3 files changed
+389 -24

No files matched your search

@@ -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<string, string> = {
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);
}
});
});
@@ -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<string, string> = {
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<string> {
const out = new Set<string>();
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>): 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<string> {
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);
}
});
});
+36 -24
View File
@@ -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) {