diff --git a/tests/integration/backup-column-audit.ts b/tests/integration/backup-column-audit.ts new file mode 100644 index 000000000..298eed297 --- /dev/null +++ b/tests/integration/backup-column-audit.ts @@ -0,0 +1,323 @@ +/** + * Column-level bookkeeping for the backup round trip. + * + * The round-trip test proves that a row of every two-ended model comes back. + * It does not prove the row comes back WHOLE: a restore that writes the row + * with a column defaulted returns the right count and loses the value. That is + * how the medication category went missing, and how the aggregation + * provenance did before it. + * + * So this reads every column of every two-ended model from the client's own + * data model (the generated DMMF projection Prisma ships inside the client), + * fills the ones the fixture left empty with a value that cannot be mistaken + * for a default, and compares each column before the export with the same + * column after the restore. A column added to a model tomorrow is in that list + * automatically, so it is compared automatically: it either comes back, or it + * is named in the exclusions with the reason it should not. + * + * Attributes the runtime data model does not carry (optional, list, default, + * id, foreign key) are read from `schema.prisma`, and the two field lists are + * required to agree, so neither source can quietly drift from the other. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encrypt } from "@/lib/crypto"; +import { readDeclaredSchema } from "@/__tests__/helpers/prisma-schema-names"; + +const SCHEMA_PATH = resolve(__dirname, "../../prisma/schema.prisma"); + +export interface AuditColumn { + model: string; + name: string; + kind: "scalar" | "enum"; + type: string; + isList: boolean; + isOptional: boolean; + isId: boolean; + isUpdatedAt: boolean; + /** Named in an `@relation(fields: [...])` on this model. */ + isForeignKey: boolean; + /** The `@default(...)` argument as written, or null. */ + defaultText: string | null; +} + +export interface AuditModel { + name: string; + delegate: string; + columns: AuditColumn[]; + /** Fields of the primary key, `@id` or `@@id([...])`. */ + primaryKey: string[]; + /** To-one relations: field name -> target model. */ + parents: Record; +} + +interface RuntimeField { + name: string; + kind: "scalar" | "enum" | "object"; + type: string; +} + +interface RuntimeDataModel { + models: Record; +} + +function runtimeDataModel(prisma: PrismaClient): RuntimeDataModel { + const rdm = (prisma as unknown as { _runtimeDataModel?: RuntimeDataModel }) + ._runtimeDataModel; + if (!rdm?.models) { + throw new Error( + "the Prisma client no longer exposes its data model; the column audit " + + "cannot list columns and must not pass on an empty list", + ); + } + return rdm; +} + +interface SchemaFieldAttrs { + isList: boolean; + isOptional: boolean; + isId: boolean; + isUpdatedAt: boolean; + defaultText: string | null; +} + +/** Field attributes and block attributes of one `model` block. */ +function schemaModelAttrs(source: string, model: string) { + const start = source.search(new RegExp(`^model ${model} \\{`, "m")); + if (start === -1) throw new Error(`model ${model} not in schema.prisma`); + const end = source.indexOf("\n}", start); + const lines = source + .slice(start, end) + .split("\n") + .slice(1) + .filter((line) => !/^\s*\/\//.test(line)); + + const fields = new Map(); + const foreignKeys = new Set(); + let compoundId: string[] | null = null; + + for (const line of lines) { + const block = /^\s*@@id\(\[([^\]]+)\]/.exec(line); + if (block) { + compoundId = block[1].split(",").map((s) => s.trim()); + continue; + } + const m = /^\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*(.*)$/.exec(line); + if (!m) continue; + const [, name, , list, optional, rest] = m; + const rel = /@relation\([^)]*fields:\s*\[([^\]]+)\]/.exec(rest); + if (rel) { + for (const fk of rel[1].split(",")) foreignKeys.add(fk.trim()); + } + const def = /@default\(((?:[^()]|\([^()]*\))*)\)/.exec(rest); + fields.set(name, { + isList: Boolean(list), + isOptional: Boolean(optional), + isId: /(^|\s)@id\b/.test(rest), + isUpdatedAt: /@updatedAt\b/.test(rest), + defaultText: def ? def[1].trim() : null, + }); + } + return { fields, foreignKeys, compoundId }; +} + +export function readAuditModels( + prisma: PrismaClient, + models: readonly string[], +): AuditModel[] { + const rdm = runtimeDataModel(prisma); + const source = readFileSync(SCHEMA_PATH, "utf8"); + + return models.map((name) => { + const runtime = rdm.models[name]; + if (!runtime) throw new Error(`${name} is not in the client data model`); + const attrs = schemaModelAttrs(source, name); + + const columns: AuditColumn[] = []; + const parents: Record = {}; + for (const field of runtime.fields) { + const a = attrs.fields.get(field.name); + if (!a) { + throw new Error( + `${name}.${field.name} is in the client data model but not in ` + + `schema.prisma; regenerate the client`, + ); + } + if (field.kind === "object") { + if (!a.isList) parents[field.name] = field.type; + continue; + } + columns.push({ + model: name, + name: field.name, + kind: field.kind, + type: field.type, + isList: a.isList, + isOptional: a.isOptional, + isId: a.isId, + isUpdatedAt: a.isUpdatedAt, + isForeignKey: attrs.foreignKeys.has(field.name), + defaultText: a.defaultText, + }); + } + + const primaryKey = + attrs.compoundId ?? columns.filter((c) => c.isId).map((c) => c.name); + if (primaryKey.length === 0) throw new Error(`${name} has no primary key`); + + return { + name, + delegate: name.charAt(0).toLowerCase() + name.slice(1), + columns, + primaryKey, + parents, + }; + }); +} + +/** + * The `where` that selects the rows one account owns: its own `userId` when + * the model has one, otherwise through the nearest to-one parent that does. + */ +export function ownerWhere( + models: ReadonlyMap, + prisma: PrismaClient, + model: string, + userId: string, + depth = 0, +): Record { + const rdm = runtimeDataModel(prisma); + const fields = rdm.models[model].fields; + if (fields.some((f) => f.name === "userId" && f.kind === "scalar")) { + return { userId }; + } + if (depth > 3) throw new Error(`no owner path for ${model}`); + const parents = + models.get(model)?.parents ?? + Object.fromEntries( + fields.filter((f) => f.kind === "object").map((f) => [f.name, f.type]), + ); + for (const [field, target] of Object.entries(parents)) { + try { + return { + [field]: ownerWhere(models, prisma, target, userId, depth + 1), + }; + } catch { + continue; + } + } + throw new Error(`no owner path for ${model}`); +} + +/** A comparable, order-independent rendering of one value. */ +export function normalise(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (value instanceof Date) return `date:${value.toISOString()}`; + if (value instanceof Uint8Array) { + return `bytes:${Buffer.from(value).toString("base64")}`; + } + if (typeof value === "bigint") return `bigint:${value}`; + if (typeof value === "object" && "toFixed" in (value as object)) { + return `decimal:${String(value)}`; + } + return JSON.stringify(value, (_k, v) => + v && typeof v === "object" && !Array.isArray(v) + ? Object.fromEntries( + Object.entries(v as object).sort(([a], [b]) => a.localeCompare(b)), + ) + : v, + ); +} + +/** Is this value the column's declared default (or its empty state)? */ +export function isDefaultValue(column: AuditColumn, value: unknown): boolean { + if (value === null || value === undefined) return true; + if (column.isList && Array.isArray(value) && value.length === 0) return true; + const d = column.defaultText; + if (d === null) return false; + if (/^(now|uuid|cuid|autoincrement|dbgenerated)\(/.test(d)) return false; + const literal = d.replace(/^"(.*)"$/, "$1"); + if (column.type === "Json") { + try { + return normalise(value) === normalise(JSON.parse(literal)); + } catch { + return false; + } + } + if (column.isList && Array.isArray(value)) { + const items = d + .replace(/^\[|\]$/g, "") + .split(",") + .map((item) => item.trim().replace(/^"(.*)"$/, "$1")) + .filter((item) => item.length > 0); + return normalise(value.map(String)) === normalise(items); + } + return String(value) === literal; +} + +/** + * A value of the column's type that is not its default, for a column the + * fixture left empty. `overrides` supplies one where the type alone does not + * produce something the export and the restore accept. + */ +export function syntheticValue( + column: AuditColumn, + enumValues: ReadonlyMap, + overrides: Readonly>, +): unknown { + const key = `${column.model}.${column.name}`; + if (key in overrides) { + const v = overrides[key]; + return typeof v === "function" ? (v as () => unknown)() : v; + } + const tag = `rt-${column.model}-${column.name}`; + const sealed = /Encrypted$/.test(column.name); + let one: unknown; + if (column.kind === "enum") { + const values = enumValues.get(column.type) ?? []; + const literal = column.defaultText; + one = values.find((v) => v !== literal) ?? values[0]; + } else { + switch (column.type) { + case "String": + one = sealed ? encrypt(tag) : tag; + break; + case "Int": + one = column.defaultText === "3" ? 4 : 3; + break; + case "BigInt": + one = BigInt(5); + break; + case "Float": + case "Decimal": + one = column.defaultText === "2.5" ? 3.5 : 2.5; + break; + case "Boolean": + one = column.defaultText !== "true"; + break; + case "DateTime": + one = new Date("2026-06-30T12:34:56.789Z"); + break; + case "Json": + one = { roundTrip: tag }; + break; + case "Bytes": + // The codec most sealed byte columns share; a column on another one + // names its own in the overrides. + one = encryptToBytes(tag); + break; + default: + throw new Error(`no synthetic value for ${key} (${column.type})`); + } + } + return column.isList ? [one] : one; +} + +export function enumValuesFromSchema(): Map { + return new Map( + readDeclaredSchema(SCHEMA_PATH).enums.map((e) => [e.name, e.values]), + ); +} diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 1046fee29..c955cb2d5 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -43,13 +43,15 @@ * is a thing a restore could get wrong while returning the right number of * rows, which is the only test worth writing here. */ +import { createHash } from "node:crypto"; + import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; import type { PrismaClient } from "@/generated/prisma/client"; -import { encrypt, encryptBytes } from "@/lib/crypto"; +import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { readNote } from "@/lib/crypto/note-cipher"; import { @@ -69,11 +71,25 @@ import { } from "@/lib/medication-category"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { decryptNoteFromBytes } from "@/lib/labs/store"; -import { decryptContextFromBytes } from "@/lib/labs/biomarker-store"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; +import { parseOnboardingSteps } from "@/lib/onboarding/needs"; import { POST } from "./restore-job-driver"; +import { + type AuditColumn, + type AuditModel, + enumValuesFromSchema, + isDefaultValue, + normalise, + ownerWhere, + readAuditModels, + syntheticValue, +} from "./backup-column-audit"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -3212,3 +3228,416 @@ describe("every model the plan claims two-ended survives a real restore", () => }); } }); + +/** + * Columns the restore is right not to bring back verbatim, by `Model.column`. + * Every other column of every two-ended model must come back with the value + * it left with. An entry here whose column no longer exists fails the run. + */ +const COLUMN_EXCLUSIONS: Readonly> = { + "MeasurementReminder.lastNotifiedAt": + "operational repeat cursor, documented on the column as not in the backup: a restored reminder re-reminds at most once, on its next notify hour", + // When a join row was written. Nothing reads these: the links are read for + // which tag, symptom or context applies, never for when the row appeared, + // and the parent entry carries its own timestamps, which do travel. + "MoodContext.createdAt": + "insert stamp of the per-entry context row; no reader, the mood entry's own timestamps travel", + "MoodEntryTagLink.createdAt": + "insert stamp of a tag link; no reader, the mood entry's own timestamps travel", + "CycleSymptomLink.createdAt": + "insert stamp of a symptom link; no reader, the day log's own timestamps travel", + "IllnessSymptomLink.createdAt": + "insert stamp of a symptom link; no reader, the illness day log's own timestamps travel", +}; + +/** + * Columns every reader parses through one function before using, compared + * through that same function: the restore writes the parsed form. + */ +const READ_THROUGH: Readonly unknown>> = { + "OnboardingRecord.stepsJson": parseOnboardingSteps, +}; + +/** + * Values for columns the fixture leaves empty where the type alone does not + * produce one the export and the restore accept. + */ +const FILL_OVERRIDES: Readonly> = { + "MedicationSchedule.rrule": "FREQ=WEEKLY;BYDAY=MO,TH", + "InboundDocument.sourceInstance": "https://paperless.example.test", + // The digest of the fixture's own document bytes, so a restore that checks + // it against the content finds them consistent. + "InboundDocument.contentSha256": createHash("sha256") + .update("round-trip document bytes") + .digest("hex"), + "Biomarker.contextEncrypted": () => + encryptContextToBytes("fasted, morning draw"), + "MoodEntry.tags": JSON.stringify(["round-trip-tag"]), + "CorrelationPattern.qValue": 0.04, + "CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32), +}; + +/** + * Rows for the columns a CHECK constraint ties to a shape the base fixture + * does not have: a stage only on a sleep reading, a context only on a glucose + * reading, a rolling interval only on a schedule without a calendar rule. + */ +async function seedColumnShapes(prisma: PrismaClient): Promise { + await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "BLOOD_GLUCOSE", + value: 5.4, + unit: "mmol/L", + measuredAt: AT("2026-07-03T06:45:00.000Z"), + source: "MANUAL", + glucoseContext: "FASTING", + }, + }); + await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "SLEEP_DURATION", + value: 95, + unit: "min", + measuredAt: AT("2026-07-03T02:00:00.000Z"), + source: "APPLE_HEALTH", + externalId: "round-trip-deep-sleep", + sleepStage: "DEEP", + }, + }); + // A start logged out of order and folded into the one before it: soft + // deleted, and pointing at the start that absorbed it so that removing that + // start can bring this one back. + const absorbing = await prisma.menstrualCycle.findFirstOrThrow({ + where: { userId: OWNER_ID, startDate: "2026-06-01" }, + }); + await prisma.menstrualCycle.create({ + data: { + userId: OWNER_ID, + startDate: "2026-06-03", + deletedAt: AT("2026-06-04T07:00:00.000Z"), + absorbedIntoId: absorbing.id, + }, + }); + const medication = await prisma.medication.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + await prisma.medicationSchedule.create({ + data: { + medicationId: medication.id, + windowStart: "20:00", + windowEnd: "21:00", + label: "Every third evening", + rollingIntervalDays: 3, + }, + }); +} + +type Delegate = { + findMany(args: { + where: Record; + }): Promise[]>; + update(args: { + where: Record; + data: Record; + }): Promise; +}; + +function delegateOf(prisma: PrismaClient, model: AuditModel): Delegate { + return (prisma as unknown as Record)[model.delegate]; +} + +function primaryKeyWhere( + model: AuditModel, + row: Record, +): Record { + if (model.primaryKey.length === 1) { + return { [model.primaryKey[0]]: row[model.primaryKey[0]] }; + } + return { + [model.primaryKey.join("_")]: Object.fromEntries( + model.primaryKey.map((k) => [k, row[k]]), + ), + }; +} + +/** How a column is compared, or why it is not. */ +function comparison( + column: AuditColumn, +): "value" | "presence" | { excluded: string } { + const key = `${column.model}.${column.name}`; + if (key in COLUMN_EXCLUSIONS) return { excluded: COLUMN_EXCLUSIONS[key] }; + if (column.isId) { + return { excluded: "primary key; the restore may mint a fresh one" }; + } + if (column.name === "userId") { + return { excluded: "owner; re-derived from the restoring account" }; + } + if (column.isUpdatedAt) { + return { excluded: "@updatedAt; Prisma stamps it on the restore's write" }; + } + // A declared foreign key may be re-pointed at a row the restore minted, so + // it is held to being set where it was set, not to the same id. + if (column.isForeignKey) return "presence"; + return "value"; +} + +/** Plaintext behind a sealed value, or the value itself when it is not one. */ +function opened(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "string") { + try { + return `text:${decrypt(value)}`; + } catch { + return `text:${value}`; + } + } + if (value instanceof Uint8Array) { + for (const open of BYTE_OPENERS) { + try { + return `text:${open(value)}`; + } catch { + continue; + } + } + } + return normalise(value); +} + +/** Every codec a sealed byte column is written with. */ +const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [ + decryptFromBytes, + decryptNoteFromBytes, + decryptContextFromBytes, + (value) => decryptBytes(Buffer.from(value)).toString("base64"), +]; + +/** + * A legacy plaintext column beside its sealed twin (`note` / `noteEncrypted`) + * is one value in two places: the restore seals what it finds in either, and + * clears the plaintext. So the pair is compared as the value a reader sees. + */ +function readerValue( + row: Record, + column: AuditColumn, +): string { + const parse = READ_THROUGH[`${column.model}.${column.name}`]; + if (parse) return normalise(parse(row[column.name])); + const twin = `${column.name}Encrypted`; + if (twin in row) { + const sealed = opened(row[twin]); + return sealed !== "null" ? sealed : opened(row[column.name]); + } + return opened(row[column.name]); +} + +function columnValues( + rows: Record[], + column: AuditColumn, + how: "value" | "presence", +): string[] { + return rows + .map((row) => + how === "presence" + ? row[column.name] === null || row[column.name] === undefined + ? "null" + : "set" + : readerValue(row, column), + ) + .sort(); +} + +async function snapshot( + prisma: PrismaClient, + models: AuditModel[], + wheres: Map>, +): Promise[]>> { + const out = new Map[]>(); + for (const model of models) { + out.set( + model.name, + await delegateOf(prisma, model).findMany({ + where: wheres.get(model.name)!, + }), + ); + } + return out; +} + +/** + * Columns whose value differs between two snapshots, by `Model.column`. Pure, + * so the test can show it catches a column it was never told about. + */ +function lostColumns( + models: AuditModel[], + before: Map[]>, + after: Map[]>, +): { lost: string[]; compared: number } { + const lost: string[] = []; + let compared = 0; + for (const model of models) { + for (const column of model.columns) { + const how = comparison(column); + if (typeof how === "object") continue; + const plain = column.name.replace(/Encrypted$/, ""); + if ( + plain !== column.name && + model.columns.some((c) => c.name === plain) + ) { + continue; // compared through its plaintext twin, see readerValue + } + compared += 1; + const was = columnValues(before.get(model.name) ?? [], column, how); + const is = columnValues(after.get(model.name) ?? [], column, how); + if (normalise(was) !== normalise(is)) { + lost.push(`${model.name}.${column.name}`); + } + } + } + return { lost: lost.sort(), compared }; +} + +describe("every column of every two-ended model survives a real restore", () => { + it("brings each column back with the value it left with", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + // A catalogue row the fixture adds, which survives both the account delete + // and the per-test truncation; left behind by the first test in this file. + await prisma.illnessSymptom.deleteMany({ + where: { key: "round_trip_cough" }, + }); + await seedEveryTwoEndedModel(prisma); + await seedColumnShapes(prisma); + + const models = readAuditModels(prisma, TWO_ENDED_MODELS); + const byName = new Map(models.map((m) => [m.name, m])); + const wheres = new Map( + models.map((m) => [m.name, ownerWhere(byName, prisma, m.name, OWNER_ID)]), + ); + const enumValues = enumValuesFromSchema(); + + // Fill what the fixture left empty, so an empty column cannot come back + // empty and read as carried. One column at a time on one row, so a value + // the database refuses names its column instead of failing the batch. + const unfillable: string[] = []; + const seededRows = await snapshot(prisma, models, wheres); + for (const model of models) { + const rows = seededRows.get(model.name)!; + expect(rows.length, `${model.name} seeded no row`).toBeGreaterThan(0); + for (const column of model.columns) { + if (comparison(column) !== "value") continue; + // Filling it would turn the fixture's row into a tombstone, and which + // models carry tombstones is a row decision, not a column one. + if (column.name === "deletedAt") continue; + if (rows.some((row) => !isDefaultValue(column, row[column.name]))) { + continue; + } + try { + await delegateOf(prisma, model).update({ + where: primaryKeyWhere(model, rows[0]), + data: { + [column.name]: syntheticValue(column, enumValues, FILL_OVERRIDES), + }, + }); + } catch (error) { + unfillable.push( + `${model.name}.${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`, + ); + } + } + } + expect(unfillable, "columns the fill could not set").toEqual([]); + + const before = await snapshot(prisma, models, wheres); + + const exportedAt = new Date("2026-08-01T00:00:00.000Z"); + let json = ""; + await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + json += chunk; + }, + { purpose: "disaster-recovery", exportedAt }, + ); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "COLUMN_ROUND_TRIP", + data: await legacyStreamedBlobFrom(async (write) => { + await write(json); + }), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const after = await snapshot(prisma, models, wheres); + const { lost, compared } = lostColumns(models, before, after); + + // A comparison over no columns agrees with everything. 632 at the time of + // writing; the headroom is for a column dropped by a migration, not for a + // data model that stopped being read. + expect(compared).toBeGreaterThanOrEqual(620); + expect( + lost, + "these columns left with a value and came back without it. Carry each " + + "through the export and the restore, or name it in COLUMN_EXCLUSIONS " + + "with the reason the restore is right to drop it", + ).toEqual([]); + }); + + it("notices a column nobody told it about", () => { + const model: AuditModel = { + name: "Probe", + delegate: "probe", + primaryKey: ["id"], + parents: {}, + columns: [ + { + model: "Probe", + name: "addedTomorrow", + kind: "scalar", + type: "String", + isList: false, + isOptional: true, + isId: false, + isUpdatedAt: false, + isForeignKey: false, + defaultText: null, + }, + ], + }; + const before = new Map([["Probe", [{ id: "a", addedTomorrow: "kept" }]]]); + const after = new Map([["Probe", [{ id: "b", addedTomorrow: null }]]]); + expect(lostColumns([model], before, after)).toEqual({ + lost: ["Probe.addedTomorrow"], + compared: 1, + }); + }); + + it("names no exclusion for a column that no longer exists", () => { + const models = readAuditModels(getPrismaClient(), TWO_ENDED_MODELS); + const known = new Set( + models.flatMap((m) => m.columns.map((c) => `${m.name}.${c.name}`)), + ); + const stale = [ + ...Object.keys(COLUMN_EXCLUSIONS), + ...Object.keys(READ_THROUGH), + ...Object.keys(FILL_OVERRIDES), + ].filter((key) => !known.has(key)); + expect(stale).toEqual([]); + }); +});