diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9701fa010..fa1aeaa28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5886,8 +5886,20 @@ model PushAttempt { /// while the database trigger requires it to equal `recipientUserId`. userId String @map("user_id") /// The health-record subject. This supplies calculation context and content. + /// + /// Optional HERE and `NOT NULL` in the database, deliberately, and the two do + /// not disagree: `push_attempt_attribution_guard` is a BEFORE INSERT trigger, + /// so a write that omits both principals has them filled from `userId` before + /// the constraint is checked. Declaring them required would force every + /// established self-delivery writer to name a pair it has no opinion about; + /// making the columns nullable in SQL would let a partial pair reach the + /// table, which the trigger exists to refuse. `prisma migrate diff` reports + /// the difference because it cannot see the trigger — that report is expected, + /// and `notification-attribution.test.ts` ("persistence maps an omitted pair + /// from the legacy recipient") is what proves the accommodation still works. recordUserId String? @map("record_user_id") /// The account whose channel, preferences, and locale receive the delivery. + /// Nullable for the same reason as `recordUserId` above. recipientUserId String? @map("recipient_user_id") channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" eventType String @map("event_type") // dispatcher event-type string diff --git a/scripts/verify-v137-adversarial.ts b/scripts/verify-v137-adversarial.ts new file mode 100644 index 000000000..fab06f39c --- /dev/null +++ b/scripts/verify-v137-adversarial.ts @@ -0,0 +1,305 @@ +/** + * v1.37.0 integration verifier — the inventories, and whether any of them is empty. + * + * ## What this is for + * + * The release is proved by inventories: a migration allocation, a set of + * admitted mutating handlers, a notification delivery matrix, a set of security + * principals, five accessibility states and eight journeys. Every one of them is + * consumed by a test that iterates it, and every one of those tests passes + * trivially when the list it iterates is empty. That is the failure this script + * exists to catch, and it is not hypothetical — three separate checks in this + * repository have been found green because their matcher matched nothing. + * + * So the rule here is that a count is asserted before it is used, and asserted + * against a number written down rather than against "more than zero" alone. + * A list that lost half its entries is not caught by a non-empty check. + * + * ## What this is NOT + * + * It does not run the suites and it cannot tell whether the tests that consume + * these inventories assert anything worth asserting. It answers one question — + * "is there something in each of these, and is it the amount there is supposed + * to be" — and leaves the rest to the tests themselves. + * + * ## Usage + * + * pnpm dlx tsx scripts/verify-v137-adversarial.ts # everything + * pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-inventories # counts only + * pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-migrations # allocation only + * + * Exits non-zero on the first failing assertion, and prints every check it ran + * so a run that checked nothing is visible as a run that printed nothing. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + ACCESSIBILITY_STATES, + SHARING_ACCESSIBILITY_STATES, + V137_E2E_JOURNEYS, +} from "../tests/fixtures/v137/e2e-journeys"; +import { NOTIFICATION_DELIVERY_MATRIX } from "../tests/fixtures/v137/notification-matrix"; +import { SECURITY_PRINCIPALS } from "../tests/fixtures/v137/security-principals"; +import { ADMITTED_MUTATING_HANDLERS } from "../tests/fixtures/v137/sharing-matrix"; + +const ROOT = process.cwd(); + +let failures = 0; +let checks = 0; + +function check(label: string, ok: boolean, detail: string): void { + checks += 1; + if (ok) { + console.log(` ok ${label} — ${detail}`); + return; + } + console.error(` FAIL ${label} — ${detail}`); + failures += 1; +} + +function expectCount(label: string, actual: number, expected: number): void { + check(label, actual === expected, `${actual} (expected ${expected})`); +} + +/* -------------------------------------------------------------------------- */ +/* Migration allocation */ +/* -------------------------------------------------------------------------- */ + +/** + * The release's allocation, corrected. The synthesis said 0296-0299 and called + * the range frozen; the release shipped seven. The list is here so a builder + * inventing an eighth number, or a merge quietly renumbering one of these, + * fails rather than being noticed at the release audit or not at all. + */ +const MIGRATION_ALLOCATION = [ + "0296_account_grant_scope_and_manage", + "0297_managed_profile", + "0298_notification_delivery_identity", + "0299_medication_intake_import_actor_attribution", + "0300_notification_event_created_at_index", + "0301_notification_egress_authorization", + "0302_session_record_epoch", +] as const; + +/** + * Triggers this release installs. Neither is modelled in the Prisma schema, so + * `migrate diff` has nothing to compare them against and cannot report one + * going missing — the drift check is structurally blind here. Their presence in + * the migration text is the cheapest honest signal; the behaviour is proved by + * the integration suites named beside each one. + */ +const MIGRATION_TRIGGERS: ReadonlyArray<{ + trigger: string; + migration: string; + provenBy: string; +}> = [ + { + trigger: "push_attempt_attribution_guard", + migration: "0298_notification_delivery_identity", + provenBy: "tests/integration/notification-attribution.test.ts", + }, + { + trigger: "sessions_record_epoch_bump", + migration: "0302_session_record_epoch", + provenBy: "tests/integration/record-session-epoch-trigger.test.ts", + }, +]; + +function checkMigrations(): void { + console.log("migration allocation"); + const dir = join(ROOT, "prisma/migrations"); + const present = readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + check( + "the migrations directory really loaded", + present.length > 100, + `${present.length} migrations on disk`, + ); + + const release = present.filter((name) => /^(029[6-9]|030[0-2])_/.test(name)); + expectCount( + "the release allocates exactly seven", + release.length, + MIGRATION_ALLOCATION.length, + ); + + for (const expected of MIGRATION_ALLOCATION) { + check( + `allocated ${expected.slice(0, 4)}`, + present.includes(expected), + expected, + ); + } + + // Every number used once. A collision is the thing renumbering exists to + // prevent, and two directories sharing a prefix apply in an order nobody + // chose. + const releaseNumbers = release.map((name) => name.slice(0, 4)); + const duplicated = releaseNumbers.filter( + (n, i) => releaseNumbers.indexOf(n) !== i, + ); + check( + "no number is allocated twice in the release range", + duplicated.length === 0, + duplicated.length === 0 ? "none" : duplicated.join(", "), + ); + + for (const { trigger, migration, provenBy } of MIGRATION_TRIGGERS) { + const sql = readFileSync(join(dir, migration, "migration.sql"), "utf8"); + check( + `${migration} installs ${trigger}`, + sql.includes(trigger), + `behaviour proved by ${provenBy}`, + ); + } +} + +/* -------------------------------------------------------------------------- */ +/* Inventories */ +/* -------------------------------------------------------------------------- */ + +function checkInventories(): void { + console.log("inventories"); + + // Handlers. Every admitted mutation is meant to run level, owner, actor, + // effect and encrypted-field cases; an empty list runs none of them and the + // matrix test reports the same green either way. + check( + "the admitted mutating handlers are non-empty", + ADMITTED_MUTATING_HANDLERS.length > 0, + `${ADMITTED_MUTATING_HANDLERS.length} handlers`, + ); + // Keyed on route AND action, because one route legitimately appears more + // than once: an `[id]` route carries an update and a delete, and they are + // different contracts with different admitted levels. Keying on the route + // alone reported eleven false collisions on the first run of this script. + const handlerKeys = new Set( + ADMITTED_MUTATING_HANDLERS.map((h) => `${h.action} ${h.route}`), + ); + expectCount( + "no route and action pair is admitted twice", + handlerKeys.size, + ADMITTED_MUTATING_HANDLERS.length, + ); + check( + "every handler names a module that exists", + ADMITTED_MUTATING_HANDLERS.every((h) => { + try { + return ( + readFileSync(join(ROOT, "src", h.handlerModule), "utf8").length > 0 + ); + } catch { + return false; + } + }), + "each handlerModule resolves under src/", + ); + + // The notification matrix is two records by two Guardians by four channels. + // Written as a product, so a dropped channel shows up here as sixteen minus + // four rather than as a suite that simply ran fewer cases. + expectCount( + "the notification delivery matrix is 2 records x 2 Guardians x 4 channels", + NOTIFICATION_DELIVERY_MATRIX.length, + 16, + ); + check( + "no delivery in the matrix is interactive", + NOTIFICATION_DELIVERY_MATRIX.every((row) => row.interactive === false), + "a Guardian's notification offers no action buttons", + ); + + // Security principals. Each names an actor, a record and a recipient; a + // principal missing one of the three would let a test assert against + // undefined and pass. + const principals = Object.entries(SECURITY_PRINCIPALS); + check( + "the security principals are non-empty", + principals.length > 0, + `${principals.length} principals`, + ); + for (const [name, principal] of principals) { + const { actorUserId, recordUserId, recipientUserId } = principal.identities; + check( + `${name} names all three identities`, + Boolean(actorUserId && recordUserId && recipientUserId), + `${actorUserId} / ${recordUserId} / ${recipientUserId}`, + ); + } + + // Accessibility states and journeys. Both are enumerations the browser suite + // is read back against, and both are exact rather than non-empty: a ninth + // journey nobody counted and a fifth state quietly absorbed into a fourth are + // the two failures the enumeration exists to make visible. + expectCount( + "exactly five accessibility states", + ACCESSIBILITY_STATES.length, + 5, + ); + expectCount( + "every state carries a case", + SHARING_ACCESSIBILITY_STATES.length, + 5, + ); + expectCount("exactly eight journeys", V137_E2E_JOURNEYS.length, 8); + expectCount( + "every journey id is distinct", + new Set(V137_E2E_JOURNEYS.map((j) => j.id)).size, + 8, + ); + + // Each journey id must appear in a test title somewhere under e2e/ or tests/ + // or src/__tests__/. `sharing-accessibility-states.test.tsx` pins WHICH file + // carries each one; this leg is the cheaper cross-check that none of the + // eight has become a claim with no test anywhere at all. + const searched = [ + ...readdirSync(join(ROOT, "e2e")).map((f) => join("e2e", f)), + ...readdirSync(join(ROOT, "tests/integration")).map((f) => + join("tests/integration", f), + ), + ...readdirSync(join(ROOT, "src/__tests__")).map((f) => + join("src/__tests__", f), + ), + ].filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); + check( + "the test tree really loaded", + searched.length > 50, + `${searched.length} files scanned`, + ); + const corpus = searched + .map((f) => readFileSync(join(ROOT, f), "utf8")) + .join("\n"); + for (const journey of V137_E2E_JOURNEYS) { + check( + `${journey.id} is marked in a test title`, + corpus.includes(`[${journey.id}]`), + journey.claim.slice(0, 60) + "…", + ); + } +} + +/* -------------------------------------------------------------------------- */ + +const argv = process.argv.slice(2); +const only = argv.filter((a) => a.startsWith("--")); +const runMigrations = only.length === 0 || only.includes("--check-migrations"); +const runInventories = + only.length === 0 || only.includes("--check-inventories"); + +if (runMigrations) checkMigrations(); +if (runInventories) checkInventories(); + +// A run that asserted nothing is a run that proves nothing, and it would exit 0. +if (checks === 0) { + console.error("no checks ran — refusing to report success"); + process.exit(1); +} + +console.log( + `\n${checks} check(s), ${failures} failure(s)` + + (failures === 0 ? " — inventories are non-empty and exact" : ""), +); +process.exit(failures === 0 ? 0 : 1);