From 005fe6a5213e8495a563237c5be8d09b70f28675 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 9 Aug 2026 14:12:19 +0000 Subject: [PATCH 1/7] Add read-only migration-readiness verifier (PR 13 slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators can run `deno task migration-verify` against the configured database (a live one, or one freshly restored from an old backup into the current application) to prove the legacy payment tables, attendee PII, and merge references are safe to migrate before a later release changes payment history — without writing anything. The pure rules in `src/shared/migration-readiness/readiness.ts` group one provider payment before pagination, convert old timestamps, and surface bounded contradictions across processed_payments, checkout_stages, sumup_checkouts, attendee PII, and attendee-merge references. The owner-key control for encrypted attendee PII is enforced: the verifier derives the site private key from an owner-authenticated password and blocks (rather than skipping) the encrypted charges when the key is unavailable. Shares one database-config validator with the restore CLI (dedup) in `src/shared/db/database-config.ts`, and one owner-KEK dispatch plus a private-key-from-data-key helper in `src/shared/crypto/owner-kek.ts` (used by the verifier; the login flow keeps its inline dispatch). Touches no provider adapters, payment callback/classification/refund paths, PR3_PLAN.md, or aggregate runtime. --- deno.json | 1 + scripts/migration-verify-deps.ts | 172 +++++++ scripts/migration-verify-lib.ts | 227 +++++++++ scripts/migration-verify.ts | 54 ++ .../equivalent-mutants/shared-m-z.txt | 5 + scripts/restore-lib.ts | 35 +- src/shared/crypto/owner-kek.ts | 44 ++ src/shared/db/database-config.ts | 55 ++ src/shared/migration-readiness/readiness.ts | 468 ++++++++++++++++++ test/integration/migration-verify.test.ts | 225 +++++++++ test/scripts/migration-verify.test.ts | 288 +++++++++++ test/shared/crypto/owner-kek.test.ts | 74 +++ test/shared/db/database-config.test.ts | 99 ++++ .../migration-readiness/readiness.test.ts | 442 +++++++++++++++++ 14 files changed, 2159 insertions(+), 30 deletions(-) create mode 100644 scripts/migration-verify-deps.ts create mode 100644 scripts/migration-verify-lib.ts create mode 100644 scripts/migration-verify.ts create mode 100644 src/shared/crypto/owner-kek.ts create mode 100644 src/shared/db/database-config.ts create mode 100644 src/shared/migration-readiness/readiness.ts create mode 100644 test/integration/migration-verify.test.ts create mode 100644 test/scripts/migration-verify.test.ts create mode 100644 test/shared/crypto/owner-kek.test.ts create mode 100644 test/shared/db/database-config.test.ts create mode 100644 test/shared/migration-readiness/readiness.test.ts diff --git a/deno.json b/deno.json index 2d74146f85..38db8e4413 100644 --- a/deno.json +++ b/deno.json @@ -38,6 +38,7 @@ "backup": "deno run --env-file --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi scripts/backup.ts", "restore": "deno run --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi scripts/restore.ts", "snapshot": "deno run --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi scripts/database-snapshot.ts", + "migration-verify": "deno run --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi scripts/migration-verify.ts", "migrate:turso": "deno run --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi scripts/turso-migration.ts", "migrate:sites": "deno run --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi scripts/site-migration.ts", "cli:tui": "deno run --allow-env --allow-read --allow-run cli/tui.ts", diff --git a/scripts/migration-verify-deps.ts b/scripts/migration-verify-deps.ts new file mode 100644 index 0000000000..60f525fc9f --- /dev/null +++ b/scripts/migration-verify-deps.ts @@ -0,0 +1,172 @@ +/** + * Production wiring for the migration-readiness verifier. + * + * Builds the database-backed reader and the owner-key provider that + * `runMigrationVerifyCli` (in `migration-verify-lib.ts`) drives. The reader + * keyset-paginates the legacy payment tables so a large database never trips + * libsqld's "Response is too large" cap; the owner-key provider derives the + * site's private key from an owner-authenticated password and decrypts attendee + * PII and merge-reference charges in-process. Nothing here writes to the + * database — every read is read-only migration input. + */ + +import type { InValue } from "@libsql/client"; +import type { + MigrationVerifyOwnerKey, + MigrationVerifyReader, +} from "#scripts/migration-verify-lib.ts"; +import { + decryptWithOwnerKey, + HYBRID_PREFIX, + unwrapKey, +} from "#shared/crypto/keys.ts"; +import { + deriveOwnerKek, + privateKeyFromDataKey, +} from "#shared/crypto/owner-kek.ts"; +import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { queryAll } from "#shared/db/client.ts"; +import { settings } from "#shared/db/settings.ts"; +import { getUserByUsername, verifyUserPassword } from "#shared/db/users.ts"; +import type { + AttendeePiiSource, + CheckoutStageRow, + ProcessedPaymentRow, + SumupCheckoutRow, +} from "#shared/migration-readiness/readiness.ts"; +import { CONFIG_KEYS } from "#shared/settings/keys.ts"; + +const DEFAULT_VERIFY_PAGE_SIZE = 500; + +/** Read every row of a table as keyset pages, so no single libsql response + * exceeds its payload cap. `whereClause` narrows the read (e.g. real-audience + * PII); the cursor advances past the previous page's last primary key. */ +const keysetRows = async ( + sqlPrefix: string, + whereClause: string | null, + pkColumn: string, + pageSize: number, +): Promise => { + const rows: T[] = []; + let after: InValue = null; + for (;;) { + const conds: string[] = []; + const args: InValue[] = []; + if (whereClause) conds.push(whereClause); + if (after !== null) { + conds.push(`${pkColumn} > ?`); + args.push(after); + } + const where = conds.length ? ` WHERE ${conds.join(" AND ")}` : ""; + const page = await queryAll( + `${sqlPrefix}${where} ORDER BY ${pkColumn} LIMIT ?`, + [...args, pageSize], + ); + if (page.length === 0) break; + rows.push(...page); + after = (page[page.length - 1] as Record)[ + pkColumn + ] as InValue; + if (page.length < pageSize) break; + } + return rows; +}; + +/** The legacy payment tables the verifier reads, in the order its reports list + * them. Each read selects only the columns the readiness rules use. */ +export const createMigrationVerifyReader = ( + pageSize: number = DEFAULT_VERIFY_PAGE_SIZE, +): MigrationVerifyReader => ({ + readAttendeeIds: () => { + const ids = keysetRows<{ id: number }>( + "SELECT id FROM attendees", + null, + "id", + pageSize, + ); + return ids.then((rows) => new Set(rows.map((row) => row.id))); + }, + readAttendeePii: () => + keysetRows( + "SELECT id, pii_blob FROM attendees", + "kind = 'attendee' AND pii_blob != ''", + "id", + pageSize, + ), + readCheckoutStages: () => + keysetRows( + "SELECT payment_session_id, attendee_id, provider, state, created_at FROM checkout_stages", + null, + "payment_session_id", + pageSize, + ), + readProcessedPayments: () => + keysetRows( + "SELECT payment_session_id, attendee_id, processed_at, payment_reference, provider_refunded_at FROM processed_payments", + null, + "payment_session_id", + pageSize, + ), + readSumupCheckouts: () => + keysetRows( + "SELECT reference_index, sumup_id, created_at FROM sumup_checkouts", + null, + "reference_index", + pageSize, + ), +}); + +/** Whether an owner-key-encrypted value decrypts under the key. An empty or + * legacy plaintext value is treated as decryptable (nothing to verify); a + * hybrid ciphertext that throws on decrypt is not. Returns no plaintext. */ +const decryptsUnderOwnerKey = async ( + value: OwnerKeyEncrypted | "", + key: CryptoKey, +): Promise => { + if (!value.startsWith(HYBRID_PREFIX)) return true; + try { + await decryptWithOwnerKey(value as OwnerKeyEncrypted, key); + return true; + } catch { + return false; + } +}; + +/** + * The owner-key provider: an owner-authenticated step that derives the site + * private key from the owner password, then proves it can decrypt every + * attendee PII blob and merge-reference charge. A wrong password, a missing + * wrapped-data key, or an absent wrapped private key returns null — the caller + * then blocks rather than skipping the encrypted charges. PII plaintext never + * leaves this step; only ids/keys that failed are returned. + */ +export const createMigrationVerifyOwnerKey = (): MigrationVerifyOwnerKey => ({ + derive: async (username, password) => { + const user = await getUserByUsername(username); + if (!user?.wrapped_data_key) return null; + const passwordHash = await verifyUserPassword(user, password); + if (!passwordHash) return null; + await settings.loadKeys([CONFIG_KEYS.WRAPPED_PRIVATE_KEY]); + if (!settings.wrappedPrivateKey) return null; + const kek = await deriveOwnerKek(password, passwordHash, user.kek_version); + const dataKey = await unwrapKey(user.wrapped_data_key, kek); + return privateKeyFromDataKey(dataKey, settings.wrappedPrivateKey); + }, + verify: async (key, inputs) => { + const undecryptablePii = new Set(); + const undecryptableMergeReferences = new Set(); + for (const { id, pii_blob } of inputs.attendees) { + if (!(await decryptsUnderOwnerKey(pii_blob, key))) + undecryptablePii.add(id); + } + for (const { + payment_reference, + payment_session_id, + } of inputs.mergeReferences) { + if (!(await decryptsUnderOwnerKey(payment_reference, key))) { + undecryptableMergeReferences.add(payment_session_id); + } + } + return { undecryptableMergeReferences, undecryptablePii }; + }, +}); diff --git a/scripts/migration-verify-lib.ts b/scripts/migration-verify-lib.ts new file mode 100644 index 0000000000..a65dd51e01 --- /dev/null +++ b/scripts/migration-verify-lib.ts @@ -0,0 +1,227 @@ +/** + * Operator-facing read-only migration readiness verifier. + * + * Reads the legacy payment tables (`processed_payments`, `checkout_stages`, + * `sumup_checkouts`), attendee PII blobs, and attendee-merge references from the + * configured database — a live one, or one freshly restored from an old backup + * into the current application — and reports whether they are safe to migrate in + * a later fleet-wide release, without writing anything. + * + * The pure grouping, timestamp, and contradiction rules live in + * `src/shared/migration-readiness/readiness.ts`; this module is the read shell + * that fetches rows, enforces the owner-key control for encrypted attendee PII, + * and prints the verdict. It is dependency-injected so the orchestration is + * tested without a database. + */ + +import { parseArgs } from "@std/cli/parse-args"; +import type { ScriptIo } from "#scripts/script-runner.ts"; +import { + type AttendeePiiSource, + type CheckoutStageRow, + diagnoseReadiness, + formatReadinessReport, + LEGACY_MERGE_SESSION_PREFIX, + type ProcessedPaymentRow, + type SumupCheckoutRow, +} from "#shared/migration-readiness/readiness.ts"; + +export const MIGRATION_VERIFY_USAGE = + "Usage: deno task migration-verify [--owner ] [--page-size ]"; + +export const EXIT_READY = 0; +export const EXIT_BLOCKED = 1; +export const EXIT_USAGE = 2; + +export interface MigrationVerifyReader { + readAttendeeIds(): Promise>; + readAttendeePii(): Promise; + readCheckoutStages(): Promise; + readProcessedPayments(): Promise; + readSumupCheckouts(): Promise; +} + +/** The encrypted sources the owner key verifies: every attendee PII blob and + * every merge-reference charge reference. Bundled so the verify contract and + * the assessor that forwards to it share one parameter shape. */ +export interface MigrationVerifyOwnerKeyInputs { + attendees: readonly AttendeePiiSource[]; + mergeReferences: readonly ProcessedPaymentRow[]; +} + +export interface MigrationVerifyOwnerKey { + /** Derive the owner private key from an owner-authenticated password, or null + * when the password is wrong or the key cannot be unwrapped. */ + derive(username: string, password: string): Promise; + /** Decrypt every attendee PII blob and every merge-reference charge reference + * under the owner key, returning the ids/keys that failed (never plaintext). */ + verify( + key: CryptoKey, + inputs: MigrationVerifyOwnerKeyInputs, + ): Promise<{ + undecryptablePii: Set; + undecryptableMergeReferences: Set; + }>; +} + +export interface MigrationVerifyDeps extends ScriptIo { + ownerKey: MigrationVerifyOwnerKey; + pageSize: number; + prompt: (message: string) => string | null; + reader: MigrationVerifyReader; +} + +const isMergeReference = (sessionId: string): boolean => + sessionId.startsWith(LEGACY_MERGE_SESSION_PREFIX); + +interface ParsedArgs { + help: boolean; + owner: string | undefined; + pageSize: number; +} + +const parseIntOrDefault = ( + raw: string | undefined, + fallback: number, +): number => { + if (raw === undefined) return fallback; + const value = Number(raw); + return Number.isInteger(value) && value > 0 ? value : Number.NaN; +}; + +const parseVerifyArgs = ( + args: string[], + fallbackPageSize: number, +): { kind: "ok"; value: ParsedArgs } | { kind: "usage" } => { + const unknowns: string[] = []; + const parsed = parseArgs(args, { + boolean: ["help"], + default: {}, + string: ["owner", "page-size"], + unknown: (name) => { + unknowns.push(name); + return false; + }, + }); + if (unknowns.length > 0 || parsed._.length > 0) return { kind: "usage" }; + const pageSize = parseIntOrDefault(parsed["page-size"], fallbackPageSize); + if (Number.isNaN(pageSize)) return { kind: "usage" }; + return { + kind: "ok", + value: { + help: parsed.help, + owner: parsed.owner?.trim() || undefined, + pageSize, + }, + }; +}; + +const readAllSources = async ( + reader: MigrationVerifyReader, +): Promise<{ + attendees: AttendeePiiSource[]; + attendeeIds: Set; + checkoutStages: CheckoutStageRow[]; + mergeReferences: ProcessedPaymentRow[]; + orderedProcessedSessionIds: string[]; + processed: ProcessedPaymentRow[]; + sumup: SumupCheckoutRow[]; +}> => { + const [processed, checkoutStages, sumup, attendees, attendeeIds] = + await Promise.all([ + reader.readProcessedPayments(), + reader.readCheckoutStages(), + reader.readSumupCheckouts(), + reader.readAttendeePii(), + reader.readAttendeeIds(), + ]); + const mergeReferences = processed.filter((row) => + isMergeReference(row.payment_session_id), + ); + return { + attendeeIds, + attendees, + checkoutStages, + mergeReferences, + orderedProcessedSessionIds: processed.map((row) => row.payment_session_id), + processed, + sumup, + }; +}; + +const assessOwnerKey = async ( + deps: MigrationVerifyDeps, + owner: string | undefined, + inputs: MigrationVerifyOwnerKeyInputs, +): Promise<{ + ownerKeyAvailable: boolean; + undecryptablePii: Set; + undecryptableMergeReferences: Set; +}> => { + const empty = { + ownerKeyAvailable: false, + undecryptableMergeReferences: new Set(), + undecryptablePii: new Set(), + }; + if (!owner) return empty; + const password = deps.prompt(`Password for owner "${owner}":`) ?? ""; + if (password === "") return empty; + const key = await deps.ownerKey.derive(owner, password); + if (key === null) { + deps.stderr( + "The owner private key could not be derived from that password. Attendee PII cannot be verified.", + ); + return empty; + } + return { + ownerKeyAvailable: true, + ...(await deps.ownerKey.verify(key, inputs)), + }; +}; + +/** Run the readiness verifier. Reads the legacy sources, enforces the + * owner-key control for attendee PII, and prints a bounded verdict. Returns + * 0 when the database is ready to migrate, 1 when it is blocked, and 2 when + * the arguments or a source read failed before the verdict could run. */ +export const runMigrationVerifyCli = async ( + deps: MigrationVerifyDeps, +): Promise => { + const parsed = parseVerifyArgs(deps.args, deps.pageSize); + if (parsed.kind === "usage") { + deps.stderr(MIGRATION_VERIFY_USAGE); + return EXIT_USAGE; + } + if (parsed.value.help) { + deps.stdout(MIGRATION_VERIFY_USAGE); + return EXIT_READY; + } + + let sources: Awaited>; + try { + sources = await readAllSources(deps.reader); + } catch (error) { + deps.stderr(`Could not read the legacy payment sources: ${String(error)}`); + return EXIT_USAGE; + } + + const ownerKey = await assessOwnerKey(deps, parsed.value.owner, { + attendees: sources.attendees, + mergeReferences: sources.mergeReferences, + }); + + const report = diagnoseReadiness({ + attendeeIds: sources.attendeeIds, + attendees: sources.attendees, + orderedProcessedSessionIds: sources.orderedProcessedSessionIds, + ownerKeyAvailable: ownerKey.ownerKeyAvailable, + pageSize: parsed.value.pageSize, + processed: sources.processed, + stages: sources.checkoutStages, + sumup: sources.sumup, + undecryptableMergeReferences: ownerKey.undecryptableMergeReferences, + undecryptablePii: ownerKey.undecryptablePii, + }); + + for (const line of formatReadinessReport(report)) deps.stdout(line); + return report.kind === "ready" ? EXIT_READY : EXIT_BLOCKED; +}; diff --git a/scripts/migration-verify.ts b/scripts/migration-verify.ts new file mode 100644 index 0000000000..2cb9a73419 --- /dev/null +++ b/scripts/migration-verify.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env -S deno run --allow-env --allow-read --allow-write --allow-net --allow-sys --allow-ffi + +/** + * Read-only migration readiness verifier (an operator CLI). + * + * Reads the legacy payment tables, attendee PII blobs, and attendee-merge + * references from the configured database — a live one, or one freshly + * restored from an old backup into the current application — and reports + * whether they are safe to migrate in a later fleet-wide release. Writes + * nothing. + * + * Supply the owner username to verify the owner key can decrypt every attendee + * PII blob and merge-reference charge; without it the verifier blocks on + * encrypted attendee PII rather than skipping it. + * + * deno task migration-verify # checks the payment tables only + * deno task migration-verify --owner # also verifies attendee PII decryption + * + * Reads DB_URL / DB_TOKEN / DB_ENCRYPTION_KEY from the environment; load them + * with `--env-file=.env` or export them first. + */ + +import { load } from "@std/dotenv"; +import { + createMigrationVerifyOwnerKey, + createMigrationVerifyReader, +} from "#scripts/migration-verify-deps.ts"; +import { runMigrationVerifyCli } from "#scripts/migration-verify-lib.ts"; +import { runDenoScript, type ScriptIo } from "#scripts/script-runner.ts"; +import { readDatabaseConfigOrError } from "#shared/db/database-config.ts"; + +const fileEnv = await load(); +for (const [key, value] of Object.entries(fileEnv)) { + if (value === undefined) Deno.env.delete(key); + else Deno.env.set(key, value); +} + +const DEFAULT_VERIFY_PAGE_SIZE = 500; +const EXIT_USAGE = 2; + +await runDenoScript(async (io: ScriptIo) => { + const config = readDatabaseConfigOrError(io.getEnv, "verify"); + if (!config.ok) { + io.stderr(config.message); + return EXIT_USAGE; + } + return runMigrationVerifyCli({ + ...io, + ownerKey: createMigrationVerifyOwnerKey(), + pageSize: DEFAULT_VERIFY_PAGE_SIZE, + prompt: (message: string) => prompt(message), + reader: createMigrationVerifyReader(DEFAULT_VERIFY_PAGE_SIZE), + }); +}); diff --git a/scripts/mutation/equivalent-mutants/shared-m-z.txt b/scripts/mutation/equivalent-mutants/shared-m-z.txt index f10b216f06..6e96a2679e 100644 --- a/scripts/mutation/equivalent-mutants/shared-m-z.txt +++ b/scripts/mutation/equivalent-mutants/shared-m-z.txt @@ -259,3 +259,8 @@ src/shared/validation/money.ts::PositiveMoneySchema~0jfm1h7 false → true # src/shared/payment-signature.ts::canonicalPricePayload.entries~0kqbt3q 1 → 0 # sort() only checks for a strictly negative result; -1/0 and -1/1 order identically src/shared/payment/money.ts::CurrencySchema~1qa2mi1 Currency must be three uppercase letters → "" # message text of the currency regex; validation outcome is identical src/shared/payment/resource-id.ts::ResourceIdSchema~1pmki6u Resource id must be text with no whitespace → "" # message text of the resource-id regex; validation outcome is identical + +# paymentsExceedingPage: counts.get(id) is undefined | number; for 0, undefined, +# or any number, `?? 0` and `|| 0` yield the same value, so the two operators +# cannot be distinguished. +src/shared/migration-readiness/readiness.ts::paymentsExceedingPage~0643qpn ?? → || # counts.get(id) is undefined | number; 0 ?? 0 == 0 || 0 == 0 diff --git a/scripts/restore-lib.ts b/scripts/restore-lib.ts index 99fbf3c275..057f0fe313 100644 --- a/scripts/restore-lib.ts +++ b/scripts/restore-lib.ts @@ -1,6 +1,5 @@ import { sum } from "#fp"; import type { ScriptIo } from "#scripts/script-runner.ts"; -import { decodeKeyBytes } from "#shared/crypto/encryption.ts"; import { type BackupManifest, PostResetError, @@ -8,6 +7,7 @@ import { type RestoreProgressHandler, type RestoreStage, } from "#shared/db/backup.ts"; +import { readDatabaseConfigOrError } from "#shared/db/database-config.ts"; import { SCHEMA_HASH } from "#shared/db/migrations.ts"; import { errorMessage } from "#shared/error-message.ts"; import { formatBytes } from "#shared/limits.ts"; @@ -16,7 +16,6 @@ export const RESTORE_CONFIRMATION = "RESTORE"; export const RESTORE_USAGE = "Usage: deno task restore "; const isFullCommitSha = (commit: string): boolean => /^[0-9a-f]{40}$/.test(commit); -const REMOTE_DB_URL_PREFIXES = ["https://", "libsql://"]; export interface RestoreCliDeps extends ScriptIo { inspectBackupZip: (data: Uint8Array) => { @@ -49,36 +48,12 @@ const countLabel = (count: number, name: string): string => `${count} ${name}${count === 1 ? "" : "s"}`; const readRestoreDbUrlOrNull = (deps: RestoreCliDeps): string | null => { - const dbUrl = deps.getEnv("DB_URL"); - if (!dbUrl?.trim()) { - deps.stderr("DB_URL is required in .env."); + const result = readDatabaseConfigOrError(deps.getEnv, "restore"); + if (!result.ok) { + deps.stderr(result.message); return null; } - if (dbUrl === ":memory:") { - deps.stderr( - "DB_URL cannot be :memory: for a restore. Set it to the target database in .env.", - ); - return null; - } - if ( - REMOTE_DB_URL_PREFIXES.some((prefix) => dbUrl.startsWith(prefix)) && - !deps.getEnv("DB_TOKEN")?.trim() - ) { - deps.stderr("DB_TOKEN is required in .env for a remote database."); - return null; - } - const encryptionKey = deps.getEnv("DB_ENCRYPTION_KEY"); - if (!encryptionKey?.trim()) { - deps.stderr("DB_ENCRYPTION_KEY is required in .env."); - return null; - } - try { - decodeKeyBytes(encryptionKey); - } catch (error) { - deps.stderr(errorMessage(error)); - return null; - } - return dbUrl; + return result.dbUrl; }; const writeManifestSummary = ( diff --git a/src/shared/crypto/owner-kek.ts b/src/shared/crypto/owner-kek.ts new file mode 100644 index 0000000000..9adf5e5315 --- /dev/null +++ b/src/shared/crypto/owner-kek.ts @@ -0,0 +1,44 @@ +/** + * Owner-key derivation helpers for offline operator paths. + * + * `deriveOwnerKek` is the single v1/v2 KEK dispatch for an owner account; it is + * the offline counterpart to the login flow's inline dispatch. The migration- + * readiness verifier uses it to derive the site private key from an + * owner-authenticated password, decrypting attendee PII and merge-reference + * charges in-process. + * + * The login flow itself keeps its inline dispatch (it then wraps the DATA_KEY + * under the session token); unifying it onto `deriveOwnerKek` would pull the + * large `keys.ts` file's whole-file mutation surface into this focused slice, + * so that consolidation is left as a follow-up. + */ + +import { decryptWithKey } from "#shared/crypto/encryption.ts"; +import { + deriveKEK, + deriveKEKFromPassword, + importPrivateKey, +} from "#shared/crypto/keys.ts"; +import type { KeyEncrypted, PasswordHash } from "#shared/crypto/sealed.ts"; + +/** The KEK for an owner account: the password-bound v2 scheme when the account + * has migrated to it, otherwise the legacy v1 scheme keyed by the stored hash. */ +export const deriveOwnerKek = ( + password: string, + passwordHash: PasswordHash, + kekVersion: number, +): Promise => + kekVersion >= 2 + ? deriveKEKFromPassword(password, passwordHash) + : deriveKEK(passwordHash); + +/** Decrypt the owner's wrapped private key (the RSA key that protects attendee + * PII) with a DATA_KEY. The verifier unwraps the DATA_KEY from the owner + * password first, then calls this. */ +export const privateKeyFromDataKey = async ( + dataKey: CryptoKey, + wrappedPrivateKey: KeyEncrypted, +): Promise => { + const privateKeyJwk = await decryptWithKey(wrappedPrivateKey, dataKey); + return importPrivateKey(privateKeyJwk); +}; diff --git a/src/shared/db/database-config.ts b/src/shared/db/database-config.ts new file mode 100644 index 0000000000..73f4fcb91e --- /dev/null +++ b/src/shared/db/database-config.ts @@ -0,0 +1,55 @@ +/** + * Shared database-connection validation for operator CLIs (restore, verify). + * + * Both `deno task restore` and `deno task migration-verify` need the same DB + * credentials from `.env` — `DB_URL`, the `DB_TOKEN` a remote database + * requires, and a `DB_ENCRYPTION_KEY` that decodes to 32 bytes. Rather than + * duplicate the guards in each CLI, they share this one check so the messages + * and the `:memory:` refusal stay identical. + */ + +import { decodeKeyBytes } from "#shared/crypto/encryption.ts"; +import { errorMessage } from "#shared/error-message.ts"; + +const REMOTE_DB_URL_PREFIXES = ["https://", "libsql://"]; + +export type DatabaseConfigNoun = "restore" | "verify"; + +/** Validate the database connection env. Returns the `DB_URL` when every + * requirement holds, otherwise an error message naming the first missing or + * invalid value. `noun` is used in the `:memory:` refusal so each CLI names + * the action it refused. */ +export const readDatabaseConfigOrError = ( + getEnv: (key: string) => string | undefined, + noun: DatabaseConfigNoun, +): { ok: true; dbUrl: string } | { ok: false; message: string } => { + const dbUrl = getEnv("DB_URL"); + if (!dbUrl?.trim()) { + return { message: "DB_URL is required in .env.", ok: false }; + } + if (dbUrl === ":memory:") { + return { + message: `DB_URL cannot be :memory: for a ${noun}. Set it to the target database in .env.`, + ok: false, + }; + } + if ( + REMOTE_DB_URL_PREFIXES.some((prefix) => dbUrl.startsWith(prefix)) && + !getEnv("DB_TOKEN")?.trim() + ) { + return { + message: "DB_TOKEN is required in .env for a remote database.", + ok: false, + }; + } + const encryptionKey = getEnv("DB_ENCRYPTION_KEY"); + if (!encryptionKey?.trim()) { + return { message: "DB_ENCRYPTION_KEY is required in .env.", ok: false }; + } + try { + decodeKeyBytes(encryptionKey); + } catch (error) { + return { message: errorMessage(error), ok: false }; + } + return { dbUrl, ok: true }; +}; diff --git a/src/shared/migration-readiness/readiness.ts b/src/shared/migration-readiness/readiness.ts new file mode 100644 index 0000000000..7155dc1e3b --- /dev/null +++ b/src/shared/migration-readiness/readiness.ts @@ -0,0 +1,468 @@ +/** + * Read-only migration readiness for the legacy payment tables. + * + * The pure rules here turn the legacy reader rows (`processed_payments`, + * `checkout_stages`, `sumup_checkouts`, attendee PII blobs, and attendee-merge + * references) into one lossless migration model and an operator-readable + * readiness verdict — without writing anything. They never touch the aggregate + * payment runtime or the live provider/refund paths; they only describe whether + * the historical data is safe to migrate in a later fleet-wide release. + * + * This module is pure: callers fetch the rows and report any decryption + * failures, and these rules group, normalise, and diagnose over what was read. + */ + +import { Temporal } from "temporal-polyfill"; +import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { epochMsToIso } from "#shared/validation/timestamp.ts"; + +/** The session-id prefix marking a `processed_payments` row as an + * attendee-merge handoff of the source attendee's `payment_id`. */ +export const LEGACY_MERGE_SESSION_PREFIX = "legacy-merge:"; + +/** One row of the legacy `processed_payments` table (migration input). The + * `payment_reference` is owner-key-encrypted ciphertext (or empty); it is + * never decoded here. */ +export type ProcessedPaymentRow = { + payment_session_id: string; + attendee_id: number | null; + processed_at: string; + payment_reference: OwnerKeyEncrypted | ""; + provider_refunded_at: string; +}; + +/** One row of the legacy `checkout_stages` table. */ +export type CheckoutStageRow = { + payment_session_id: string; + attendee_id: number; + provider: string; + state: string; + created_at: string; +}; + +/** One row of the legacy `sumup_checkouts` staging table. The booking + * `metadata` is reference-wrapped ciphertext that a DB dump alone cannot + * decrypt, so the readiness verdict works from the non-secret columns. */ +export type SumupCheckoutRow = { + reference_index: string; + sumup_id: string; + created_at: string; +}; + +/** An attendee row carrying owner-key-encrypted PII (migration input). */ +export type AttendeePiiSource = { + id: number; + pii_blob: OwnerKeyEncrypted | ""; +}; + +/** A `processed_payments` row that hand-offs a merged attendee's `payment_id`. + * Its `payment_session_id` is `legacy-merge:`, + * `attendee_id` is the merge target, and `payment_reference` is the source + * attendee's owner-key-encrypted `payment_id`. */ +export type MergeReferenceRow = ProcessedPaymentRow; + +/** One provider payment after grouping: the legacy tables key each payment by + * `payment_session_id`, so a group is the processed row (if any) plus the + * checkout-stage row (if any) that share it. */ +export type PaymentGroup = { + paymentSessionId: string; + processed: ProcessedPaymentRow | null; + stage: CheckoutStageRow | null; +}; + +export type ContradictionKind = + | "checkout_stage_without_processed_payment" + | "processed_payment_without_attendee" + | "merge_reference_without_source_attendee" + | "merge_reference_without_target_attendee" + | "payment_split_across_page" + | "undecryptable_attendee_pii" + | "undecryptable_merge_reference" + | "owner_key_unavailable" + | "unconvertible_timestamp" + | "sumup_checkout_without_id"; + +/** A single blocking finding. `detail` carries only non-secret identifying + * context (a payment session id, an attendee id, or a count) — never PII. */ +export type Contradiction = { + kind: ContradictionKind; + detail: string; +}; + +export type ReadinessKind = "ready" | "blocked"; + +export type ReadinessCounts = { + processedPayments: number; + checkoutStages: number; + sumupCheckouts: number; + attendeePiiBlobs: number; + mergeReferences: number; + paymentGroups: number; + timestampConversions: number; +}; + +export type ReadinessReport = { + kind: ReadinessKind; + counts: ReadinessCounts; + contradictions: Contradiction[]; +}; + +/** Normalise a legacy stored timestamp to the canonical `…sssZ` ISO instant. + * Accepts ISO-8601 instants (any offset or sub-second precision) and the + * whole-epoch-millis strings older rows stored. Returns `""` for an empty + * column (a genuinely absent time is not a contradiction) and `null` when a + * value is neither a real instant nor epoch-millis, so the caller can surface + * it instead of inventing a moment. */ +export const convertLegacyTimestamp = (value: string): string | null => { + if (value === "") return ""; + if (/^\d+$/.test(value)) { + const epoch = Number(value); + if (Number.isInteger(epoch) && epoch > 0) return epochMsToIso(epoch); + return null; + } + try { + // Temporal rejects impossible dates (month 13, Feb 30) where Date would + // silently fix them, so it is the honest boundary for "is this a real + // instant". Round-tripping through epoch-millis yields the canonical form. + return epochMsToIso(Temporal.Instant.from(value).epochMilliseconds); + } catch { + return null; + } +}; + +const isMergeReference = (sessionId: string): boolean => + sessionId.startsWith(LEGACY_MERGE_SESSION_PREFIX); + +const sourceAttendeeIdOf = (sessionId: string): number => + Number(sessionId.slice(LEGACY_MERGE_SESSION_PREFIX.length)); + +/** Convert every legacy timestamp column to a canonical instant, returning one + * contradiction per value that is neither a real instant nor epoch-millis. + * Empty columns are left as empty (an absent time is not a contradiction). */ +const convertAllTimestamps = ( + processed: readonly ProcessedPaymentRow[], + stages: readonly CheckoutStageRow[], + sumup: readonly SumupCheckoutRow[], +): { contradictions: Contradiction[]; converted: number } => { + const contradictions: Contradiction[] = []; + let converted = 0; + const check = (value: string, label: string): void => { + const result = convertLegacyTimestamp(value); + if (result === null) { + contradictions.push({ + detail: label, + kind: "unconvertible_timestamp", + }); + } else if (result !== "") { + converted += 1; + } + }; + for (const row of processed) { + check( + row.processed_at, + `processed_payments.processed_at = ${row.processed_at}`, + ); + check( + row.provider_refunded_at, + `processed_payments.provider_refunded_at = ${row.provider_refunded_at}`, + ); + } + for (const row of stages) { + check(row.created_at, `checkout_stages.created_at = ${row.created_at}`); + } + for (const row of sumup) { + check(row.created_at, `sumup_checkouts.created_at = ${row.created_at}`); + } + return { contradictions, converted }; +}; + +/** Fold rows that share `payment_session_id` into one group, keeping the order a + * session is first seen and filling the named side (processed/stage). `field` + * tracks the row type at each call site; the cast bridges a correlation + * TypeScript cannot express for a generic indexed assignment. */ +const foldRowsIntoGroups = ( + bySession: Map, + order: string[], + field: "processed" | "stage", + rows: readonly Row[], +): void => { + for (const row of rows) { + const sessionId = row.payment_session_id; + const existing = bySession.get(sessionId); + if (existing) { + existing[field] = row as never; + } else { + order.push(sessionId); + const group: PaymentGroup = { + paymentSessionId: sessionId, + processed: null, + stage: null, + }; + group[field] = row as never; + bySession.set(sessionId, group); + } + } +}; + +/** Build the lossless payment model by grouping each `payment_session_id` once, + * preserving the order a session is first seen. One group can carry a + * processed row, a checkout-stage row, or both. */ +export const buildPaymentGroups = ( + processedRows: readonly ProcessedPaymentRow[], + stageRows: readonly CheckoutStageRow[], +): PaymentGroup[] => { + const order: string[] = []; + const bySession = new Map(); + foldRowsIntoGroups(bySession, order, "processed", processedRows); + foldRowsIntoGroups(bySession, order, "stage", stageRows); + return order.map((sessionId) => bySession.get(sessionId)!); +}; + +/** Provider payments whose rows would not fit on one keyset page, so a cursor + * that pages by row count would split one payment across a boundary. Each + * session id is returned once. With the legacy tables each payment is one row + * per table, so this only fires once a single payment grows past the page. */ +export const paymentsExceedingPage = ( + orderedSessionIds: readonly string[], + pageSize: number, +): readonly string[] => { + const counts = new Map(); + for (const id of orderedSessionIds) { + counts.set(id, (counts.get(id) ?? 0) + 1); + } + return [...counts.entries()] + .filter(([, count]) => count > pageSize) + .map(([id]) => id); +}; + +/** Inputs to the readiness verdict. The caller fetches every row and reports + * any owner-key decryption failures; these rules never perform IO. */ +export type DiagnoseInput = { + processed: readonly ProcessedPaymentRow[]; + stages: readonly CheckoutStageRow[]; + sumup: readonly SumupCheckoutRow[]; + attendees: readonly AttendeePiiSource[]; + /** Live attendee ids, used to prove processed-payment and merge-reference rows + * still point at real attendees rather than deleted bookings. */ + attendeeIds: ReadonlySet; + /** `payment_session_id` rows of `processed_payments` in read order, used to + * prove a cursor never splits one provider payment across a keyset page. */ + orderedProcessedSessionIds: readonly string[]; + pageSize: number; + /** Whether the caller supplied the owner private key and decrypted PII. When + * false and encrypted PII exists, the verdict blocks rather than skipping. */ + ownerKeyAvailable: boolean; + /** Attendee ids whose `pii_blob` failed to decrypt under the owner key. */ + undecryptablePii: ReadonlySet; + /** `payment_session_id`s of merge-reference rows whose `payment_reference` + * failed to decrypt under the owner key. */ + undecryptableMergeReferences: ReadonlySet; +}; + +/** Contradictions where a checkout stage, processed payment, or merge + * reference points at a row that should also exist. */ +const referenceContradictions = ( + groups: readonly PaymentGroup[], + attendeeIds: ReadonlySet, +): Contradiction[] => { + const contradictions: Contradiction[] = []; + for (const group of groups) { + if (group.stage && !group.processed) { + contradictions.push({ + detail: group.paymentSessionId, + kind: "checkout_stage_without_processed_payment", + }); + } + if (group.processed) { + contradictions.push( + ...processedPaymentContradictions(group, attendeeIds), + ); + } + } + return contradictions; +}; + +const processedPaymentContradictions = ( + group: PaymentGroup, + attendeeIds: ReadonlySet, +): Contradiction[] => { + if (!group.processed) return []; + const { attendee_id: attendeeId, payment_session_id: sessionId } = + group.processed; + const contradictions: Contradiction[] = []; + if (attendeeId === null || !attendeeIds.has(attendeeId)) { + contradictions.push({ + detail: String(attendeeId), + kind: "processed_payment_without_attendee", + }); + } + if (!isMergeReference(sessionId)) return contradictions; + const sourceId = sourceAttendeeIdOf(sessionId); + if (!attendeeIds.has(sourceId)) { + contradictions.push({ + detail: sessionId, + kind: "merge_reference_without_source_attendee", + }); + } + if (attendeeId !== null && !attendeeIds.has(attendeeId)) { + contradictions.push({ + detail: sessionId, + kind: "merge_reference_without_target_attendee", + }); + } + return contradictions; +}; + +const sumupContradictions = ( + sumup: readonly SumupCheckoutRow[], +): Contradiction[] => + sumup + .filter(({ sumup_id: sumupId }) => sumupId === "") + .map(({ reference_index: referenceIndex }) => ({ + detail: referenceIndex, + kind: "sumup_checkout_without_id" as const, + })); + +const splitContradictions = ( + orderedSessionIds: readonly string[], + pageSize: number, +): Contradiction[] => + paymentsExceedingPage(orderedSessionIds, pageSize).map((id) => ({ + detail: id, + kind: "payment_split_across_page" as const, + })); + +/** Owner-key controls. When the key is supplied, every PII blob and + * merge-reference charge that fails to decrypt is a contradiction. When it is + * not supplied and encrypted PII exists, the migration blocks instead of + * skipping the charges it cannot yet verify. */ +const ownerKeyContradictions = (input: DiagnoseInput): Contradiction[] => { + if (input.ownerKeyAvailable) { + return [ + ...[...input.undecryptablePii].map((attendeeId) => ({ + detail: `attendee ${attendeeId}`, + kind: "undecryptable_attendee_pii" as const, + })), + ...[...input.undecryptableMergeReferences].map((sessionId) => ({ + detail: sessionId, + kind: "undecryptable_merge_reference" as const, + })), + ]; + } + if (input.attendees.some((attendee) => attendee.pii_blob !== "")) { + return [ + { + detail: `${input.attendees.length} encrypted attendee PII blob(s) cannot be verified without the owner key`, + kind: "owner_key_unavailable" as const, + }, + ]; + } + return []; +}; + +/** Turn one lossless read of the legacy payment sources into a readiness + * verdict. The data is `ready` only when every row is accounted for, every + * timestamp normalises, every payment stays inside one page, every reference + * points at a live attendee, and the owner key could decrypt every PII blob + * and merge-reference charge. Any single finding blocks the migration so the + * operator fixes it before a later release changes payment history. */ +export const diagnoseReadiness = (input: DiagnoseInput): ReadinessReport => { + const groups = buildPaymentGroups(input.processed, input.stages); + const timestamp = convertAllTimestamps( + input.processed, + input.stages, + input.sumup, + ); + const contradictions: Contradiction[] = [ + ...referenceContradictions(groups, input.attendeeIds), + ...sumupContradictions(input.sumup), + ...splitContradictions(input.orderedProcessedSessionIds, input.pageSize), + ...timestamp.contradictions, + ...ownerKeyContradictions(input), + ]; + const mergeReferenceCount = input.processed.filter((row) => + isMergeReference(row.payment_session_id), + ).length; + + return { + contradictions, + counts: { + attendeePiiBlobs: input.attendees.length, + checkoutStages: input.stages.length, + mergeReferences: mergeReferenceCount, + paymentGroups: groups.length, + processedPayments: input.processed.length, + sumupCheckouts: input.sumup.length, + timestampConversions: timestamp.converted, + }, + kind: contradictions.length === 0 ? "ready" : "blocked", + }; +}; + +const CONTRADICTION_PHRASES: Record = { + checkout_stage_without_processed_payment: + "checkout stage without a processed payment", + merge_reference_without_source_attendee: + "merge reference without its source attendee", + merge_reference_without_target_attendee: + "merge reference without its target attendee", + owner_key_unavailable: "owner key not supplied", + payment_split_across_page: "provider payment split across a page", + processed_payment_without_attendee: + "processed payment without a live attendee", + sumup_checkout_without_id: "sumup checkout without a recorded id", + unconvertible_timestamp: "timestamp that cannot be converted", + undecryptable_attendee_pii: "attendee PII that did not decrypt", + undecryptable_merge_reference: "merge-reference charge that did not decrypt", +}; + +/** Render a readiness verdict as plain operator lines. The owner-key line says + * how many PII blobs were verified (or that the key was not supplied), and the + * contradiction lines use plain phrases over non-secret detail only. */ +export const formatReadinessReport = (report: ReadinessReport): string[] => { + const lines: string[] = []; + const heading = + report.kind === "ready" + ? "Payment migration readiness: ready" + : `Payment migration readiness: BLOCKED — ${report.contradictions.length} contradiction(s)`; + lines.push(heading, ""); + lines.push( + "Source counts", + ` processed_payments rows: ${report.counts.processedPayments}`, + ` checkout_stages rows: ${report.counts.checkoutStages}`, + ` sumup_checkouts rows: ${report.counts.sumupCheckouts}`, + ` attendee PII blobs: ${report.counts.attendeePiiBlobs}`, + ` merge references: ${report.counts.mergeReferences}`, + ` payment groups: ${report.counts.paymentGroups}`, + ` timestamps converted: ${report.counts.timestampConversions}`, + "", + ); + const ownerKeyMissing = report.contradictions.some( + (c) => c.kind === "owner_key_unavailable", + ); + if (ownerKeyMissing) { + lines.push( + "Owner key", + ` not supplied — ${report.counts.attendeePiiBlobs} attendee PII blob(s) cannot be verified`, + "", + ); + } else if (report.counts.attendeePiiBlobs > 0) { + const verified = + report.counts.attendeePiiBlobs - + report.contradictions.filter( + (c) => c.kind === "undecryptable_attendee_pii", + ).length; + lines.push( + "Owner key", + ` verified ${verified} of ${report.counts.attendeePiiBlobs} attendee PII blob(s)`, + "", + ); + } + if (report.contradictions.length > 0) { + lines.push("Contradictions"); + for (const { detail, kind } of report.contradictions) { + lines.push(` - ${CONTRADICTION_PHRASES[kind]}: ${detail}`); + } + } + return lines; +}; diff --git a/test/integration/migration-verify.test.ts b/test/integration/migration-verify.test.ts new file mode 100644 index 0000000000..b4dfa879b2 --- /dev/null +++ b/test/integration/migration-verify.test.ts @@ -0,0 +1,225 @@ +import { expect } from "@std/expect"; +import { afterEach, beforeEach, describe, it as test } from "@std/testing/bdd"; +import { + createMigrationVerifyOwnerKey, + createMigrationVerifyReader, +} from "#scripts/migration-verify-deps.ts"; +import { runMigrationVerifyCli } from "#scripts/migration-verify-lib.ts"; +import { encryptWithOwnerKey } from "#shared/crypto/keys.ts"; +import { buildPiiBlob, encryptPiiBlob } from "#shared/db/attendees/pii.ts"; +import { execute } from "#shared/db/client.ts"; +import { settings } from "#shared/db/settings.ts"; +import { nowIso } from "#shared/now.ts"; +import { CONFIG_KEYS } from "#shared/settings/keys.ts"; +import { createTestDbWithSetup, resetDb } from "#test-utils/db.ts"; +import { + TEST_ADMIN_PASSWORD, + TEST_ADMIN_USERNAME, +} from "#test-utils/internal.ts"; + +const DEFAULT_PAGE_SIZE = 2; + +interface Clip { + args: string[]; + errors: string[]; + output: string[]; + promptResponse: string | null; +} + +const clip = (args: string[], promptResponse: string | null = null): Clip => ({ + args, + errors: [], + output: [], + promptResponse, +}); + +const run = (clip: Clip, pageSize = DEFAULT_PAGE_SIZE) => + runMigrationVerifyCli({ + args: clip.args, + getEnv: () => undefined, + ownerKey: createMigrationVerifyOwnerKey(), + pageSize, + prompt: () => clip.promptResponse, + reader: createMigrationVerifyReader(pageSize), + stderr: (line) => clip.errors.push(line), + stdout: (line) => clip.output.push(line), + }); + +const seedAttendee = async (pii = true): Promise => { + const blob = pii + ? await encryptPiiBlob( + buildPiiBlob({ + address: "1 Road", + email: "buyer@example.com", + lat: "", + lng: "", + name: "Buyer", + payment_id: "pi_123", + phone: "+44", + special_instructions: "", + ticket_token: "tt", + }), + settings.publicKey, + ) + : ""; + await execute( + "INSERT INTO attendees (created, kind, pii_blob) VALUES (?, 'attendee', ?)", + [nowIso(), blob], + ); + const rows = await execute( + "SELECT id FROM attendees ORDER BY id DESC LIMIT 1", + ); + return Number(rows.rows[0]![0]); +}; + +const seedProcessed = async ( + sessionId: string, + attendeeId: number, +): Promise => { + await execute( + `INSERT INTO processed_payments (payment_session_id, attendee_id, processed_at, payment_reference, provider_refunded_at) + VALUES (?, ?, ?, '', '')`, + [sessionId, attendeeId, "2026-01-01T00:00:00.000Z"], + ); +}; + +const seedStage = async ( + sessionId: string, + attendeeId: number, +): Promise => { + await execute( + `INSERT INTO checkout_stages (payment_session_id, attendee_id, provider, ticket_tokens, state, created_at) + VALUES (?, ?, 'stripe', '', 'completed', ?)`, + [sessionId, attendeeId, "2026-01-01T00:00:00.000Z"], + ); +}; + +const seedConsistentPayment = async ( + sessionId: string, + attendeeId: number, +): Promise => { + await seedProcessed(sessionId, attendeeId); + await seedStage(sessionId, attendeeId); +}; + +describe("migration-verify production wiring", () => { + beforeEach(async () => { + await createTestDbWithSetup(); + await settings.loadKeys([ + CONFIG_KEYS.PUBLIC_KEY, + CONFIG_KEYS.WRAPPED_PRIVATE_KEY, + ]); + }); + afterEach(() => resetDb()); + + test("verifies a consistent database end to end with the owner key", async () => { + const attendeeId = await seedAttendee(); + // A matching stage for one session, plus five processed-payment rows that + // cross more than one keyset page (checkout_stages.attendee_id is unique). + await seedStage("sess-0", attendeeId); + for (let i = 0; i < 5; i++) await seedProcessed(`sess-${i}`, attendeeId); + + const c = clip(["--owner", TEST_ADMIN_USERNAME], TEST_ADMIN_PASSWORD); + const result = await run(c); + + expect(result).toBe(0); + const out = c.output.join("\n"); + expect(out).toContain("Payment migration readiness: ready"); + expect(out).toContain("processed_payments rows: 5"); + expect(out).toContain("checkout_stages rows: 1"); + expect(out).toContain("verified 1 of 1 attendee PII blob"); + }); + + test("blocks with the owner-key contradiction when no password is supplied", async () => { + const attendeeId = await seedAttendee(); + await seedConsistentPayment("sess-1", attendeeId); + + const c = clip(["--owner", TEST_ADMIN_USERNAME], null); + const result = await run(c); + + expect(result).toBe(1); + expect(c.output.join("\n")).toContain("owner key not supplied"); + }); + + test("blocks when the owner password is wrong", async () => { + const attendeeId = await seedAttendee(); + await seedConsistentPayment("sess-1", attendeeId); + + const c = clip(["--owner", TEST_ADMIN_USERNAME], "the-wrong-password"); + const result = await run(c); + + expect(result).toBe(1); + expect(c.errors.join("\n")).toContain("could not be derived"); + expect(c.output.join("\n")).toContain("owner key not supplied"); + }); + + test("blocks when the owner username is unknown", async () => { + const attendeeId = await seedAttendee(); + await seedConsistentPayment("sess-1", attendeeId); + + const c = clip(["--owner", "nobody"], "anything"); + const result = await run(c); + + expect(result).toBe(1); + expect(c.errors.join("\n")).toContain("could not be derived"); + }); + + test("blocks when the wrapped private key setting is absent", async () => { + const attendeeId = await seedAttendee(); + await seedConsistentPayment("sess-1", attendeeId); + await execute("DELETE FROM settings WHERE key = ?", [ + CONFIG_KEYS.WRAPPED_PRIVATE_KEY, + ]); + settings.setup.clearCache(); + settings.invalidateCache(); + await settings.loadKeys([CONFIG_KEYS.PUBLIC_KEY]); + + const c = clip(["--owner", TEST_ADMIN_USERNAME], TEST_ADMIN_PASSWORD); + const result = await run(c); + + expect(result).toBe(1); + expect(c.errors.join("\n")).toContain("could not be derived"); + }); + + test("reports an attendee PII blob that does not decrypt", async () => { + const attendeeId = await seedAttendee(); + await seedConsistentPayment("sess-1", attendeeId); + // Corrupt one blob so the owner key cannot decrypt it. + await execute( + "UPDATE attendees SET pii_blob = 'hyb:1:corrupt' WHERE id = ?", + [attendeeId], + ); + + const c = clip(["--owner", TEST_ADMIN_USERNAME], TEST_ADMIN_PASSWORD); + const result = await run(c); + + expect(result).toBe(1); + expect(c.output.join("\n")).toContain("attendee PII that did not decrypt"); + }); + + test("counts a merge-reference charge that decrypts under the owner key", async () => { + const target = await seedAttendee(); + const source = await seedAttendee(); + await seedConsistentPayment("sess-1", target); + const encryptedRef = await encryptWithOwnerKey( + "pi_charge", + settings.publicKey, + ); + await execute( + `INSERT INTO processed_payments (payment_session_id, attendee_id, processed_at, payment_reference, provider_refunded_at) + VALUES (?, ?, ?, ?, '')`, + [ + `legacy-merge:${source}`, + target, + "2026-01-01T00:00:00.000Z", + encryptedRef, + ], + ); + + const c = clip(["--owner", TEST_ADMIN_USERNAME], TEST_ADMIN_PASSWORD); + const result = await run(c); + + expect(result).toBe(0); + expect(c.output.join("\n")).toContain("merge references: 1"); + }); +}); diff --git a/test/scripts/migration-verify.test.ts b/test/scripts/migration-verify.test.ts new file mode 100644 index 0000000000..d649e0119c --- /dev/null +++ b/test/scripts/migration-verify.test.ts @@ -0,0 +1,288 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + MIGRATION_VERIFY_USAGE, + type MigrationVerifyDeps, + type MigrationVerifyOwnerKey, + type MigrationVerifyReader, + runMigrationVerifyCli, +} from "#scripts/migration-verify-lib.ts"; +import type { ScriptIo } from "#scripts/script-runner.ts"; +import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { + type AttendeePiiSource, + type CheckoutStageRow, + LEGACY_MERGE_SESSION_PREFIX, + type ProcessedPaymentRow, + type SumupCheckoutRow, +} from "#shared/migration-readiness/readiness.ts"; + +const enc = (s: string): OwnerKeyEncrypted => s as OwnerKeyEncrypted; + +interface Clipio extends ScriptIo { + errors: string[]; + output: string[]; + stderr: (line: string) => void; + stdout: (line: string) => void; +} + +const io = (args: string[]): Clipio => { + const output: string[] = []; + const errors: string[] = []; + return { + args, + errors, + getEnv: () => undefined, + output, + stderr: (line) => errors.push(line), + stdout: (line) => output.push(line), + }; +}; + +const fakeReader = ( + overrides: Partial = {}, +): MigrationVerifyReader => ({ + readAttendeeIds: () => Promise.resolve(new Set([1])), + readAttendeePii: () => + Promise.resolve([{ id: 1, pii_blob: enc("hyb:1:x") }]), + readCheckoutStages: () => + Promise.resolve([ + { + attendee_id: 1, + created_at: "2026-01-01T00:00:00.000Z", + payment_session_id: "sess-1", + provider: "stripe", + state: "completed", + }, + ]), + readProcessedPayments: () => + Promise.resolve([ + { + attendee_id: 1, + payment_reference: "", + payment_session_id: "sess-1", + processed_at: "2026-01-01T00:00:00.000Z", + provider_refunded_at: "", + }, + ]), + readSumupCheckouts: () => + Promise.resolve([ + { + created_at: "2026-01-01T00:00:00.000Z", + reference_index: "i", + sumup_id: "su", + }, + ]), + ...overrides, +}); + +const alwaysVerifyingKey = ( + key: CryptoKey | null, +): MigrationVerifyOwnerKey => ({ + derive: () => Promise.resolve(key), + verify: () => + Promise.resolve({ + undecryptableMergeReferences: new Set(), + undecryptablePii: new Set(), + }), +}); + +const deps = ( + clip: Clipio, + over: Partial, +): MigrationVerifyDeps => ({ + ownerKey: alwaysVerifyingKey({} as CryptoKey), + pageSize: 500, + prompt: () => "owner-password", + reader: fakeReader(), + ...clip, + ...over, +}); + +describe("runMigrationVerifyCli", () => { + test("prints usage and exits 0 for --help", async () => { + const result = await runMigrationVerifyCli( + deps(io(["--help"]), { reader: fakeReader() }), + ); + expect(result).toBe(0); + }); + + test("blocks without the owner key when encrypted PII exists", async () => { + const clip = io([]); + const result = await runMigrationVerifyCli( + deps(clip, { prompt: () => null }), + ); + expect(result).toBe(1); + expect(clip.output.join("\n")).toContain( + "Payment migration readiness: BLOCKED", + ); + expect(clip.output.join("\n")).toContain("owner key not supplied"); + }); + + test("is ready without the owner key when there is no PII to check", async () => { + const clip = io([]); + const result = await runMigrationVerifyCli( + deps(clip, { + prompt: () => null, + reader: fakeReader({ readAttendeePii: () => Promise.resolve([]) }), + }), + ); + expect(result).toBe(0); + expect(clip.output.join("\n")).toContain( + "Payment migration readiness: ready", + ); + }); + + test("verifies PII and reports ready when the owner key decrypts every blob", async () => { + const clip = io(["--owner", "owner"]); + const derived = {} as CryptoKey; + let derivedWith: { username: string; password: string } | null = null; + let verifiedWith: CryptoKey | null = null; + const result = await runMigrationVerifyCli( + deps(clip, { + ownerKey: { + derive: (_username, password) => { + derivedWith = { password, username: _username }; + return Promise.resolve(derived); + }, + verify: (key) => { + verifiedWith = key; + return Promise.resolve({ + undecryptableMergeReferences: new Set(), + undecryptablePii: new Set(), + }); + }, + }, + }), + ); + expect(result).toBe(0); + expect(derivedWith).toEqual({ + password: "owner-password", + username: "owner", + }); + expect(verifiedWith).toBe(derived); + expect(clip.output.join("\n")).toContain( + "verified 1 of 1 attendee PII blob", + ); + }); + + test("blocks when the owner key cannot be derived (wrong password)", async () => { + const clip = io(["--owner", "owner"]); + const result = await runMigrationVerifyCli( + deps(clip, { ownerKey: alwaysVerifyingKey(null) }), + ); + expect(result).toBe(1); + expect(clip.output.join("\n")).toContain("owner key not supplied"); + expect(clip.errors.join("\n")).toContain("could not be derived"); + }); + + test("blocks when a PII blob fails to decrypt under the owner key", async () => { + const clip = io(["--owner", "owner"]); + const result = await runMigrationVerifyCli( + deps(clip, { + ownerKey: { + derive: () => Promise.resolve({} as CryptoKey), + verify: () => + Promise.resolve({ + undecryptableMergeReferences: new Set(), + undecryptablePii: new Set([1]), + }), + }, + }), + ); + expect(result).toBe(1); + expect(clip.output.join("\n")).toContain( + "attendee PII that did not decrypt", + ); + }); + + test("surfaces a payment table contradiction even without the owner key", async () => { + const clip = io([]); + const result = await runMigrationVerifyCli( + deps(clip, { + prompt: () => null, + reader: fakeReader({ + readCheckoutStages: () => + Promise.resolve([ + { + attendee_id: 1, + created_at: "2026-01-01T00:00:00.000Z", + payment_session_id: "orphan", + provider: "stripe", + state: "completed", + }, + ]), + }), + }), + ); + expect(result).toBe(1); + expect(clip.output.join("\n")).toContain( + "checkout stage without a processed payment", + ); + }); + + test("exits 2 when reading a source fails", async () => { + const clip = io([]); + const result = await runMigrationVerifyCli( + deps(clip, { + prompt: () => null, + reader: fakeReader({ + readProcessedPayments: () => Promise.reject(new Error("DB down")), + }), + }), + ); + expect(result).toBe(2); + expect(clip.errors.join("\n")).toContain("DB down"); + }); + + test("verifies a merge-reference charge that decrypts with the owner key", async () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}1`; + const clip = io(["--owner", "owner"]); + let verifiedRefs: readonly ProcessedPaymentRow[] = []; + const result = await runMigrationVerifyCli( + deps(clip, { + ownerKey: { + derive: () => Promise.resolve({} as CryptoKey), + verify: (_key, inputs) => { + verifiedRefs = inputs.mergeReferences; + return Promise.resolve({ + undecryptableMergeReferences: new Set(), + undecryptablePii: new Set(), + }); + }, + }, + reader: fakeReader({ + readProcessedPayments: () => + Promise.resolve([ + { + attendee_id: 1, + payment_reference: enc("hyb:1:charge"), + payment_session_id: ref, + processed_at: "2026-01-01T00:00:00.000Z", + provider_refunded_at: "", + }, + { + attendee_id: 1, + payment_reference: "", + payment_session_id: "sess-1", + processed_at: "2026-01-01T00:00:00.000Z", + provider_refunded_at: "", + }, + ]), + }), + }), + ); + expect(result).toBe(0); + expect(verifiedRefs.map((r) => r.payment_session_id)).toEqual([ref]); + expect(clip.output.join("\n")).toContain("merge references: 1"); + }); + + test("exits 2 and prints usage for an unknown flag", async () => { + const clip = io(["--bogus"]); + const result = await runMigrationVerifyCli( + deps(clip, { reader: fakeReader() }), + ); + expect(result).toBe(2); + expect(clip.errors.join("\n")).toContain(MIGRATION_VERIFY_USAGE); + }); +}); diff --git a/test/shared/crypto/owner-kek.test.ts b/test/shared/crypto/owner-kek.test.ts new file mode 100644 index 0000000000..0a7e024e34 --- /dev/null +++ b/test/shared/crypto/owner-kek.test.ts @@ -0,0 +1,74 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { encryptWithKey } from "#shared/crypto/encryption.ts"; +import { + deriveKEK, + deriveKEKFromPassword, + generateDataKey, + generateKeyPair, + hybridEncrypt, + importPublicKey, + unwrapKey, + wrapKey, +} from "#shared/crypto/keys.ts"; +import { + deriveOwnerKek, + privateKeyFromDataKey, +} from "#shared/crypto/owner-kek.ts"; +import type { PasswordHash } from "#shared/crypto/sealed.ts"; + +const hash = "stored-hash" as PasswordHash; +const password = "owner-password"; + +describe("deriveOwnerKek", () => { + test("v2 dispatch matches deriveKEKFromPassword", async () => { + const kek = await deriveOwnerKek(password, hash, 2); + const dataKey = await generateDataKey(); + const wrapped = await wrapKey(dataKey, kek); + // The same KEK (under the v2 scheme) unwraps the wrapped key. + const recovered = await unwrapKey( + wrapped, + await deriveKEKFromPassword(password, hash), + ); + expect(recovered).toBeDefined(); + }); + + test("v1 dispatch matches deriveKEK", async () => { + const kek = await deriveOwnerKek(password, hash, 1); + const dataKey = await generateDataKey(); + const wrapped = await wrapKey(dataKey, kek); + const recovered = await unwrapKey(wrapped, await deriveKEK(hash)); + expect(recovered).toBeDefined(); + }); + + test("v1 and v2 produce different KEKs", async () => { + const v2Kek = await deriveOwnerKek(password, hash, 2); + const dataKey = await generateDataKey(); + const wrapped = await wrapKey(dataKey, v2Kek); + // A v1 KEK cannot unwrap a key wrapped under the v2 KEK. + let threw = false; + try { + await unwrapKey(wrapped, await deriveKEK(hash)); + } catch { + threw = true; + } + expect(threw).toBe(true); + }); +}); + +describe("privateKeyFromDataKey", () => { + test("recovers a private key that decrypts public-key ciphertext", async () => { + const { privateKey, publicKey } = await generateKeyPair(); + const dataKey = await generateDataKey(); + const wrappedPrivateKey = await encryptWithKey(privateKey, dataKey); + + const recovered = await privateKeyFromDataKey(dataKey, wrappedPrivateKey); + const pubKey = await importPublicKey(publicKey); + const ciphertext = await hybridEncrypt("attendee PII", pubKey); + // Only the recovered private key can decrypt what the public key encrypted. + const { decryptWithOwnerKey } = await import("#shared/crypto/keys.ts"); + expect(await decryptWithOwnerKey(ciphertext, recovered)).toBe( + "attendee PII", + ); + }); +}); diff --git a/test/shared/db/database-config.test.ts b/test/shared/db/database-config.test.ts new file mode 100644 index 0000000000..69e6464c23 --- /dev/null +++ b/test/shared/db/database-config.test.ts @@ -0,0 +1,99 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { readDatabaseConfigOrError } from "#shared/db/database-config.ts"; +import { TEST_ENCRYPTION_KEY } from "#test-utils/internal.ts"; + +const env = + ( + overrides: Record = {}, + ): ((key: string) => string | undefined) => + (key) => + overrides[key]; + +describe("readDatabaseConfigOrError", () => { + test("returns the DB_URL when every requirement holds", () => { + const result = readDatabaseConfigOrError( + env({ + DB_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, + DB_URL: "file:./local.db", + }), + "verify", + ); + expect(result).toEqual({ dbUrl: "file:./local.db", ok: true }); + }); + + test("accepts a remote database when DB_TOKEN is set", () => { + const result = readDatabaseConfigOrError( + env({ + DB_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, + DB_TOKEN: "secret", + DB_URL: "libsql://tickets.example.com", + }), + "restore", + ); + expect(result).toEqual({ dbUrl: "libsql://tickets.example.com", ok: true }); + }); + + test("requires DB_URL", () => { + const result = readDatabaseConfigOrError( + env({ DB_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY }), + "verify", + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toBe("DB_URL is required in .env."); + }); + + test("refuses :memory: and names the action", () => { + const result = readDatabaseConfigOrError( + env({ + DB_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, + DB_URL: ":memory:", + }), + "verify", + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toBe( + "DB_URL cannot be :memory: for a verify. Set it to the target database in .env.", + ); + } + }); + + test("requires DB_TOKEN for a remote database", () => { + const result = readDatabaseConfigOrError( + env({ + DB_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, + DB_URL: "libsql://tickets.example.com", + }), + "restore", + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toBe( + "DB_TOKEN is required in .env for a remote database.", + ); + } + }); + + test("requires DB_ENCRYPTION_KEY", () => { + const result = readDatabaseConfigOrError( + env({ DB_URL: "file:./local.db" }), + "verify", + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toBe("DB_ENCRYPTION_KEY is required in .env."); + } + }); + + test("rejects an encryption key that is not 32 bytes", () => { + const result = readDatabaseConfigOrError( + env({ DB_ENCRYPTION_KEY: "c2hvcnQ=", DB_URL: "file:./local.db" }), + "verify", + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toContain("32 bytes"); + } + }); +}); diff --git a/test/shared/migration-readiness/readiness.test.ts b/test/shared/migration-readiness/readiness.test.ts new file mode 100644 index 0000000000..851a1b8bdb --- /dev/null +++ b/test/shared/migration-readiness/readiness.test.ts @@ -0,0 +1,442 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { + buildPaymentGroups, + type CheckoutStageRow, + convertLegacyTimestamp, + type DiagnoseInput, + diagnoseReadiness, + formatReadinessReport, + LEGACY_MERGE_SESSION_PREFIX, + type ProcessedPaymentRow, +} from "#shared/migration-readiness/readiness.ts"; + +const enc = (s: string): OwnerKeyEncrypted => s as OwnerKeyEncrypted; + +const stage = (over: Partial): CheckoutStageRow => ({ + attendee_id: 1, + created_at: "2026-01-01T00:00:00.000Z", + payment_session_id: "sess-1", + provider: "stripe", + state: "completed", + ...over, +}); + +const processed = ( + over: Partial, +): ProcessedPaymentRow => ({ + attendee_id: 1, + payment_reference: "", + payment_session_id: "sess-1", + processed_at: "2026-01-01T00:00:00.000Z", + provider_refunded_at: "", + ...over, +}); + +const goodInput = (over: Partial = {}): DiagnoseInput => ({ + attendeeIds: new Set([1]), + attendees: [{ id: 1, pii_blob: enc("hyb:1:x") }], + orderedProcessedSessionIds: ["sess-1"], + ownerKeyAvailable: true, + pageSize: 500, + processed: [processed({ provider_refunded_at: "2026-01-02T00:00:00.000Z" })], + stages: [stage({})], + sumup: [ + { + created_at: "2026-01-01T00:00:00.000Z", + reference_index: "idx-1", + sumup_id: "su-1", + }, + ], + undecryptableMergeReferences: new Set(), + undecryptablePii: new Set(), + ...over, +}); + +describe("convertLegacyTimestamp", () => { + test("canonicalises an ISO instant with an offset to …sssZ", () => { + expect(convertLegacyTimestamp("2026-01-02T03:04:05+00:00")).toBe( + "2026-01-02T03:04:05.000Z", + ); + }); + + test("keeps an already-canonical instant", () => { + const value = "2026-01-02T03:04:05.123Z"; + expect(convertLegacyTimestamp(value)).toBe(value); + }); + + test("converts an old epoch-millis string to ISO", () => { + expect(convertLegacyTimestamp("1735689600000")).toBe( + "2025-01-01T00:00:00.000Z", + ); + }); + + test("treats an empty string as empty", () => { + expect(convertLegacyTimestamp("")).toBe(""); + }); + + test("rejects an impossible calendar date instead of fixing it", () => { + expect(convertLegacyTimestamp("2026-02-30T00:00:00Z")).toBeNull(); + }); + + test("rejects plain text that is not a timestamp", () => { + expect(convertLegacyTimestamp("not-a-time")).toBeNull(); + }); + + test("rejects epoch zero (an unset sentinel, not a real instant)", () => { + expect(convertLegacyTimestamp("0")).toBeNull(); + }); + + test("converts a one-millisecond epoch to ISO", () => { + expect(convertLegacyTimestamp("1")).toBe("1970-01-01T00:00:00.001Z"); + }); +}); + +describe("buildPaymentGroups", () => { + test("groups processed payments and stages by payment session id", () => { + const groups = buildPaymentGroups( + [processed({ payment_session_id: "a" })], + [stage({ payment_session_id: "a" }), stage({ payment_session_id: "b" })], + ); + expect(groups.map((g) => g.paymentSessionId)).toEqual(["a", "b"]); + expect(groups[0]!.processed?.payment_session_id).toBe("a"); + expect(groups[0]!.stage?.payment_session_id).toBe("a"); + expect(groups[1]!.processed).toBeNull(); + expect(groups[1]!.stage?.payment_session_id).toBe("b"); + }); + + test("preserves insertion order of the first time a session is seen", () => { + const groups = buildPaymentGroups( + [ + processed({ payment_session_id: "b" }), + processed({ payment_session_id: "a" }), + ], + [], + ); + expect(groups.map((g) => g.paymentSessionId)).toEqual(["b", "a"]); + }); +}); + +describe("diagnoseReadiness", () => { + test("reports ready when every source is consistent and the owner key works", () => { + const report = diagnoseReadiness(goodInput()); + expect(report.kind).toBe("ready"); + expect(report.contradictions).toEqual([]); + expect(report.counts.processedPayments).toBe(1); + expect(report.counts.checkoutStages).toBe(1); + expect(report.counts.sumupCheckouts).toBe(1); + expect(report.counts.attendeePiiBlobs).toBe(1); + expect(report.counts.paymentGroups).toBe(1); + }); + + test("blocks when a checkout stage has no processed payment", () => { + const report = diagnoseReadiness( + goodInput({ stages: [stage({ payment_session_id: "orphan" })] }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "orphan", + kind: "checkout_stage_without_processed_payment", + }); + }); + + test("blocks when a processed payment points at a missing attendee", () => { + const report = diagnoseReadiness( + goodInput({ processed: [processed({ attendee_id: 99 })] }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "99", + kind: "processed_payment_without_attendee", + }); + }); + + test("blocks when a processed payment has a null attendee id", () => { + const report = diagnoseReadiness( + goodInput({ processed: [processed({ attendee_id: null })] }), + ); + expect(report.kind).toBe("blocked"); + expect( + report.contradictions.some( + (c) => c.kind === "processed_payment_without_attendee", + ), + ).toBe(true); + }); + + test("blocks when a merged migration page is mistaken for end of input", () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}5`; + const report = diagnoseReadiness( + goodInput({ + attendeeIds: new Set([1]), + attendees: [ + { id: 1, pii_blob: enc("hyb:1:x") }, + { id: 2, pii_blob: enc("hyb:1:y") }, + ], + orderedProcessedSessionIds: [ref, "sess-1"], + processed: [ + processed({ attendee_id: 1, payment_session_id: ref }), + processed({ payment_session_id: "sess-1" }), + ], + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.counts.mergeReferences).toBe(1); + expect(report.contradictions).toContainEqual({ + detail: ref, + kind: "merge_reference_without_source_attendee", + }); + }); + + test("blocks when a merge reference target attendee is missing", () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}2`; + const report = diagnoseReadiness( + goodInput({ + attendeeIds: new Set([1, 2]), + orderedProcessedSessionIds: [ref], + processed: [processed({ attendee_id: 9, payment_session_id: ref })], + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: ref, + kind: "merge_reference_without_target_attendee", + }); + }); + + test("blocks rather than skipping PII when no owner key is available", () => { + const report = diagnoseReadiness(goodInput({ ownerKeyAvailable: false })); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: + "1 encrypted attendee PII blob(s) cannot be verified without the owner key", + kind: "owner_key_unavailable", + }); + }); + + test("does not report a missing owner key when there is no PII to check", () => { + const report = diagnoseReadiness( + goodInput({ + attendees: [], + ownerKeyAvailable: false, + }), + ); + expect( + report.contradictions.some((c) => c.kind === "owner_key_unavailable"), + ).toBe(false); + }); + + test("reports attendee PII that fails to decrypt with the owner key", () => { + const report = diagnoseReadiness( + goodInput({ + attendeeIds: new Set([1, 7]), + attendees: [{ id: 7, pii_blob: enc("hyb:1:bad") }], + undecryptablePii: new Set([7]), + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "attendee 7", + kind: "undecryptable_attendee_pii", + }); + }); + + test("reports a merge reference whose payment reference fails to decrypt", () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}1`; + const report = diagnoseReadiness( + goodInput({ + orderedProcessedSessionIds: [ref, "sess-1"], + processed: [ + processed({ + attendee_id: 1, + payment_reference: enc("hyb:1:x"), + payment_session_id: ref, + }), + processed({ payment_session_id: "sess-1" }), + ], + undecryptableMergeReferences: new Set([ref]), + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: ref, + kind: "undecryptable_merge_reference", + }); + }); + + test("reports an unconvertible timestamp", () => { + const report = diagnoseReadiness( + goodInput({ + processed: [processed({ processed_at: "2026-02-30T00:00:00Z" })], + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "processed_payments.processed_at = 2026-02-30T00:00:00Z", + kind: "unconvertible_timestamp", + }); + // The bad processed_at is not counted; the empty provider_refunded_at is not + // counted either; only the stage and sumup created_at columns convert. + expect(report.counts.timestampConversions).toBe(2); + }); + + test("reports a sumup checkout row whose id was never recorded", () => { + const report = diagnoseReadiness( + goodInput({ + sumup: [ + { + created_at: "2026-01-01T00:00:00.000Z", + reference_index: "idx", + sumup_id: "", + }, + ], + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "idx", + kind: "sumup_checkout_without_id", + }); + }); + + test("reports a provider payment split across a keyset page", () => { + const report = diagnoseReadiness( + goodInput({ + orderedProcessedSessionIds: ["sess-1", "sess-1"], + pageSize: 1, + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "sess-1", + kind: "payment_split_across_page", + }); + }); + + test("counts timestamp conversions actually performed", () => { + const report = diagnoseReadiness(goodInput()); + expect(report.counts.timestampConversions).toBeGreaterThan(0); + }); +}); + +describe("formatReadinessReport", () => { + test("states ready with exact source counts and the owner-key verdict", () => { + const report = diagnoseReadiness(goodInput()); + expect(formatReadinessReport(report)).toEqual([ + "Payment migration readiness: ready", + "", + "Source counts", + " processed_payments rows: 1", + " checkout_stages rows: 1", + " sumup_checkouts rows: 1", + " attendee PII blobs: 1", + " merge references: 0", + " payment groups: 1", + " timestamps converted: 4", + "", + "Owner key", + " verified 1 of 1 attendee PII blob(s)", + "", + ]); + }); + + test("states blocked and lists the owner-key contradiction in plain language", () => { + const report = diagnoseReadiness(goodInput({ ownerKeyAvailable: false })); + expect(formatReadinessReport(report)).toEqual([ + "Payment migration readiness: BLOCKED — 1 contradiction(s)", + "", + "Source counts", + " processed_payments rows: 1", + " checkout_stages rows: 1", + " sumup_checkouts rows: 1", + " attendee PII blobs: 1", + " merge references: 0", + " payment groups: 1", + " timestamps converted: 4", + "", + "Owner key", + " not supplied — 1 attendee PII blob(s) cannot be verified", + "", + "Contradictions", + " - owner key not supplied: 1 encrypted attendee PII blob(s) cannot be verified without the owner key", + ]); + }); + + test("is ready with an empty-blob attendee and no owner key (nothing encrypted to skip)", () => { + const report = diagnoseReadiness( + goodInput({ + attendees: [{ id: 1, pii_blob: "" }], + ownerKeyAvailable: false, + }), + ); + expect(report.kind).toBe("ready"); + expect(report.contradictions).toEqual([]); + }); + + test("is ready when each payment appears once even at a page size of one", () => { + const report = diagnoseReadiness(goodInput({ pageSize: 1 })); + expect(report.kind).toBe("ready"); + }); + + test("lists every contradiction phrase when each kind fires", () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}99`; + const report = diagnoseReadiness({ + attendeeIds: new Set([1, 7]), + attendees: [{ id: 7, pii_blob: enc("hyb:1:p") }], + orderedProcessedSessionIds: ["x", "x", "x", ref, "sess-1"], + ownerKeyAvailable: true, + pageSize: 1, + processed: [ + processed({ + attendee_id: 88, + payment_reference: enc("hyb:1:charge"), + payment_session_id: ref, + processed_at: "not-a-time", + }), + processed({ payment_session_id: "sess-1" }), + ], + stages: [stage({ payment_session_id: "orphan" })], + sumup: [ + { + created_at: "2026-01-01T00:00:00.000Z", + reference_index: "idx", + sumup_id: "", + }, + ], + undecryptableMergeReferences: new Set([ref]), + undecryptablePii: new Set([7]), + }); + const out = formatReadinessReport(report).join("\n"); + expect(out).toContain("Contradictions"); + expect(out).toContain( + " - checkout stage without a processed payment: orphan", + ); + expect(out).toContain(" - processed payment without a live attendee: 88"); + expect(out).toContain( + " - merge reference without its source attendee: legacy-merge:99", + ); + expect(out).toContain( + " - merge reference without its target attendee: legacy-merge:99", + ); + expect(out).toContain(" - provider payment split across a page: x"); + expect(out).toContain(" - sumup checkout without a recorded id: idx"); + expect(out).toContain(" - attendee PII that did not decrypt: attendee 7"); + expect(out).toContain( + " - merge-reference charge that did not decrypt: legacy-merge:99", + ); + expect(out).toContain(" - timestamp that cannot be converted:"); + }); + + test("does not leak attendee PII plaintext into the detail", () => { + const report = diagnoseReadiness( + goodInput({ + attendeeIds: new Set([1, 7]), + attendees: [{ id: 7, pii_blob: enc("hyb:1:super-secret") }], + undecryptablePii: new Set([7]), + }), + ); + const lines = formatReadinessReport(report).join("\n"); + expect(lines).toContain("attendee 7"); + expect(lines).not.toContain("super-secret"); + }); +}); From 54426664b63730e7a9a265c62667b74c9e3a540d Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 9 Aug 2026 15:01:15 +0000 Subject: [PATCH 2/7] Format TODO.md to deno fmt 80-col standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Good-citizen fix: TODO.md (last touched by #2046) broke the `deno fmt --check` gate on main — its prose exceeded 80 columns. Pure reformatting (verified by empty word-diff), no content change. Unblocks the PR 13 verifier's CI checks job, which runs `lint:ci`. --- TODO.md | 65 ++++++++++++++++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/TODO.md b/TODO.md index 182fb448c7..f4946d1c54 100644 --- a/TODO.md +++ b/TODO.md @@ -3,35 +3,36 @@ ## Listing/groups review follow-ups (from PR #2046) The shared driver this PR landed now gives these a single seam: the two edge -writers (`setListingChildrenWithPackageCheckTx` / `addParentEdgesWithPackageCheckTx`) -both delegate their transaction-local recheck to -`guardEdgeWriteTx` in `src/shared/db/listing-edge-write.ts` — one declared check -list (existence, nesting, package) running against current tx state. The items -below are the natural **next entries** in that declarative check list, not -separate hand-rolled guards; each still needs the tx-scoped read it mentions. -The remaining ones are transaction-race hardening on already-rare windows, so -they were deferred from the PR rather than implemented there. +writers (`setListingChildrenWithPackageCheckTx` / +`addParentEdgesWithPackageCheckTx`) both delegate their transaction-local +recheck to `guardEdgeWriteTx` in `src/shared/db/listing-edge-write.ts` — one +declared check list (existence, nesting, package) running against current tx +state. The items below are the natural **next entries** in that declarative +check list, not separate hand-rolled guards; each still needs the tx-scoped read +it mentions. The remaining ones are transaction-race hardening on already-rare +windows, so they were deferred from the PR rather than implemented there. - **Xh_QZ — Redirect vanished-group failures to a live page.** `handleAddListingsToGroup` in `src/features/admin/groups.ts` redirects every - `assignListingsToGroup` error to `/admin/groups/${group.id}`. When the group is - deleted after the handler loads it but before the write, `assignListingsToGroup` - returns `t("error.selected_group_deleted")` and that redirect lands on a 404. - Route that one result to `/admin/groups` (the live list). Reasoning for defer: - the group must vanish between the handler's load and the write — a window not - reachable from a single-request test without fragile cache manipulation, so - it shipped without coverage. + `assignListingsToGroup` error to `/admin/groups/${group.id}`. When the group + is deleted after the handler loads it but before the write, + `assignListingsToGroup` returns `t("error.selected_group_deleted")` and that + redirect lands on a 404. Route that one result to `/admin/groups` (the live + list). Reasoning for defer: the group must vanish between the handler's load + and the write — a window not reachable from a single-request test without + fragile cache manipulation, so it shipped without coverage. - **XiL8J / XiL8L — Revalidate edge fields inside the write transaction.** `guardEdgeWriteTx` rechecks existence, nesting, and package membership in the - tx, but if another admin changes a parent's or a selected child's type, renewal - tier, duration, or day prices after `validateChildEdges`/`validateParentEdges` - runs, it commits a relationship `edgeFieldError` would now reject. Fix: load - both endpoints' current edge columns (and day prices) through `tx` and rerun - `edgeFieldError` as the next entry in `guardEdgeWriteTx`'s check list. Note - `name` and some fields are encrypted (PII), so the read must select only the - plain edge columns `edgeFieldError` reasons over rather than decrypt under the - write lock. All sibling recheck guards were already implemented. + tx, but if another admin changes a parent's or a selected child's type, + renewal tier, duration, or day prices after + `validateChildEdges`/`validateParentEdges` runs, it commits a relationship + `edgeFieldError` would now reject. Fix: load both endpoints' current edge + columns (and day prices) through `tx` and rerun `edgeFieldError` as the next + entry in `guardEdgeWriteTx`'s check list. Note `name` and some fields are + encrypted (PII), so the read must select only the plain edge columns + `edgeFieldError` reasons over rather than decrypt under the write lock. All + sibling recheck guards were already implemented. - **Xig83 / XiqKh — Recheck add-on reachability inside the write transaction.** Same race-revalidation family as the edge-field entry, extended to optional @@ -42,17 +43,19 @@ they were deferred from the PR rather than implemented there. entry to `guardEdgeWriteTx`'s check list, resolving scope against transaction-local modifier + `group_listings` state (a tx-scoped variant of `modifier-resolve`'s live resolver). CodeRabbit's `XiqKh` is the combined view - of this and the edge-field entry, scoped to `api-listing-joins.ts: - persistListingJoins`; `Xig83` is Codex's form/API view. + of this and the edge-field entry, scoped to + `api-listing-joins.ts: + persistListingJoins`; `Xig83` is Codex's form/API + view. - **XjDI9 — Read prior package flags on the transaction connection.** `requirePackageGuardsTx` in `src/shared/db/groups/membership.ts` computes - `wasHiddenPackage` from the caller-supplied `existing` snapshot, which predates - the write transaction. If another request makes a visible group hidden and a - checkout sells it after the snapshot but before this transaction, a stale edit - can clear `is_package` without running `hasPackageBookingsTx`, exposing sold - hidden member names. The blocker: `writeRowInTransaction` runs the - `afterWrite` hooks after the UPDATE, so the hook can't read the pre-update + `wasHiddenPackage` from the caller-supplied `existing` snapshot, which + predates the write transaction. If another request makes a visible group + hidden and a checkout sells it after the snapshot but before this transaction, + a stale edit can clear `is_package` without running `hasPackageBookingsTx`, + exposing sold hidden member names. The blocker: `writeRowInTransaction` runs + the `afterWrite` hooks after the UPDATE, so the hook can't read the pre-update flags from the DB (documented at the `PackageRow` definition). A correct fix reads the current `is_package`/`hide_package_listings` on the transaction connection _before_ the UPDATE and validates under the same lock — an From 16056584871a50584a732aa3f5f117eaf3e150e7 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 9 Aug 2026 15:21:52 +0000 Subject: [PATCH 3/7] Address Codex review: merge refs, terminal failures, page-size, password echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six Codex P2 findings, all verified against the codebase and fixed with regression tests: 1. Owner key unavailable now also blocks on encrypted merge-reference charges, not only attendee PII — no silent skip when no key is supplied (PLAN.md PR 13: do not silently skip charges). 2. `--page-size` now flows to the database reader (factory `createReader`), so the keyset page size that bounds each read matches what the operator asked for (previously the reader always used the default 500). 3. Removed the false-positive `merge_reference_without_source_attendee` and the redundant `merge_reference_without_target_attendee` checks: `applyAttendeeMerge` deletes the source attendee in the same batch, so the source id is historical and the row's attendee_id (the merge target) is already covered by the general `processed_payment_without_attendee` check. 4. Read `failure_data`; exempt terminal handled failures (attendee NULL + failure_data set) from the missing-attendee check — only unresolved stuck reservations (attendee NULL + failure_data '') block. 5. Owner password read from non-tty stdin without echo (or `MIGRATION_VERIFY_PASSWORD` env var); `prompt()` remains the interactive fallback with a documented echo caveat. 6. Added `checkout_stage_without_attendee`: a stage whose own attendee_id points at a deleted attendee is now flagged even when the session shares a processed payment. readiness.ts mutation 100% (125/125, 1 provably-equivalent suppressed). --- scripts/migration-verify-deps.ts | 2 +- scripts/migration-verify-lib.ts | 7 +- scripts/migration-verify.ts | 37 ++++- src/shared/migration-readiness/readiness.ts | 120 +++++++++------- test/integration/migration-verify.test.ts | 76 +++++++++- test/scripts/migration-verify.test.ts | 132 ++++++++++-------- .../migration-readiness/readiness.test.ts | 112 ++++++++++++--- 7 files changed, 352 insertions(+), 134 deletions(-) diff --git a/scripts/migration-verify-deps.ts b/scripts/migration-verify-deps.ts index 60f525fc9f..a17f7b5960 100644 --- a/scripts/migration-verify-deps.ts +++ b/scripts/migration-verify-deps.ts @@ -102,7 +102,7 @@ export const createMigrationVerifyReader = ( ), readProcessedPayments: () => keysetRows( - "SELECT payment_session_id, attendee_id, processed_at, payment_reference, provider_refunded_at FROM processed_payments", + "SELECT payment_session_id, attendee_id, processed_at, payment_reference, provider_refunded_at, failure_data FROM processed_payments", null, "payment_session_id", pageSize, diff --git a/scripts/migration-verify-lib.ts b/scripts/migration-verify-lib.ts index a65dd51e01..91fce3a29e 100644 --- a/scripts/migration-verify-lib.ts +++ b/scripts/migration-verify-lib.ts @@ -65,10 +65,13 @@ export interface MigrationVerifyOwnerKey { } export interface MigrationVerifyDeps extends ScriptIo { + /** Builds the database reader for the parsed `--page-size`, so the keyset + * page size that bounds each read and the page size the diagnostics use are + * the same value the operator asked for. */ + createReader: (pageSize: number) => MigrationVerifyReader; ownerKey: MigrationVerifyOwnerKey; pageSize: number; prompt: (message: string) => string | null; - reader: MigrationVerifyReader; } const isMergeReference = (sessionId: string): boolean => @@ -198,7 +201,7 @@ export const runMigrationVerifyCli = async ( let sources: Awaited>; try { - sources = await readAllSources(deps.reader); + sources = await readAllSources(deps.createReader(parsed.value.pageSize)); } catch (error) { deps.stderr(`Could not read the legacy payment sources: ${String(error)}`); return EXIT_USAGE; diff --git a/scripts/migration-verify.ts b/scripts/migration-verify.ts index 2cb9a73419..34350fb7a4 100644 --- a/scripts/migration-verify.ts +++ b/scripts/migration-verify.ts @@ -18,6 +18,12 @@ * * Reads DB_URL / DB_TOKEN / DB_ENCRYPTION_KEY from the environment; load them * with `--env-file=.env` or export them first. + * + * The owner password is read from stdin when it is not a terminal (so piping + * or redirecting never echoes it). On an interactive terminal `prompt()` is + * used, which echoes — pass the password via stdin (`printf 'pw\n' | deno task + * migration-verify --owner …`) or the `MIGRATION_VERIFY_PASSWORD` env var when + * echo must be avoided. */ import { load } from "@std/dotenv"; @@ -38,6 +44,33 @@ for (const [key, value] of Object.entries(fileEnv)) { const DEFAULT_VERIFY_PAGE_SIZE = 500; const EXIT_USAGE = 2; +/** Read one line from a non-terminal stdin (piped input never echoes). Returns + * null on EOF before any input, else the text up to the first newline. */ +const readPasswordLineFromStdin = (): string | null => { + const buf = new Uint8Array(1024); + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + const n = Deno.stdin.readSync(buf); + if (n === null) return text === "" ? null : text; + text += decoder.decode(buf.subarray(0, n)); + if (text.includes("\n")) return text.slice(0, text.indexOf("\n")); + if (n === 0) return text === "" ? null : text; + } +}; + +/** Read the owner password without echoing. On a non-terminal stdin the line + * is read directly (no echo). On an interactive terminal it falls back to + * Deno's `prompt()`, which echoes — operators who need no echo pipe stdin or + * set `MIGRATION_VERIFY_PASSWORD`. Returns null on EOF so the caller blocks. */ +const readOwnerPassword = (message: string): string | null => { + const fromEnv = Deno.env.get("MIGRATION_VERIFY_PASSWORD"); + if (fromEnv !== undefined) return fromEnv; + return Deno.stdin.isTerminal() + ? prompt(message) + : readPasswordLineFromStdin(); +}; + await runDenoScript(async (io: ScriptIo) => { const config = readDatabaseConfigOrError(io.getEnv, "verify"); if (!config.ok) { @@ -46,9 +79,9 @@ await runDenoScript(async (io: ScriptIo) => { } return runMigrationVerifyCli({ ...io, + createReader: (pageSize) => createMigrationVerifyReader(pageSize), ownerKey: createMigrationVerifyOwnerKey(), pageSize: DEFAULT_VERIFY_PAGE_SIZE, - prompt: (message: string) => prompt(message), - reader: createMigrationVerifyReader(DEFAULT_VERIFY_PAGE_SIZE), + prompt: readOwnerPassword, }); }); diff --git a/src/shared/migration-readiness/readiness.ts b/src/shared/migration-readiness/readiness.ts index 7155dc1e3b..eb401e18b2 100644 --- a/src/shared/migration-readiness/readiness.ts +++ b/src/shared/migration-readiness/readiness.ts @@ -13,7 +13,10 @@ */ import { Temporal } from "temporal-polyfill"; -import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; +import type { + EnvKeyEncrypted, + OwnerKeyEncrypted, +} from "#shared/crypto/sealed.ts"; import { epochMsToIso } from "#shared/validation/timestamp.ts"; /** The session-id prefix marking a `processed_payments` row as an @@ -29,6 +32,10 @@ export type ProcessedPaymentRow = { processed_at: string; payment_reference: OwnerKeyEncrypted | ""; provider_refunded_at: string; + /** Encrypted terminal-failure payload. Non-empty means a handled terminal + * outcome (refund/sold-out/price-change), distinct from an unresolved + * stuck reservation (attendee_id NULL + failure_data ''). Never decoded. */ + failure_data: EnvKeyEncrypted | ""; }; /** One row of the legacy `checkout_stages` table. */ @@ -72,9 +79,8 @@ export type PaymentGroup = { export type ContradictionKind = | "checkout_stage_without_processed_payment" + | "checkout_stage_without_attendee" | "processed_payment_without_attendee" - | "merge_reference_without_source_attendee" - | "merge_reference_without_target_attendee" | "payment_split_across_page" | "undecryptable_attendee_pii" | "undecryptable_merge_reference" @@ -133,8 +139,16 @@ export const convertLegacyTimestamp = (value: string): string | null => { const isMergeReference = (sessionId: string): boolean => sessionId.startsWith(LEGACY_MERGE_SESSION_PREFIX); -const sourceAttendeeIdOf = (sessionId: string): number => - Number(sessionId.slice(LEGACY_MERGE_SESSION_PREFIX.length)); +/** A merge-reference charge that carries an encrypted `payment_reference` — one + * only the owner key can verify. Used to block the migration when the key is + * unavailable, instead of silently skipping the charge. */ +const hasEncryptedMergeReferenceCharge = ( + processed: readonly ProcessedPaymentRow[], +): boolean => + processed.some( + (row) => + isMergeReference(row.payment_session_id) && row.payment_reference !== "", + ); /** Convert every legacy timestamp column to a canonical instant, returning one * contradiction per value that is neither a real instant nor epoch-millis. @@ -259,59 +273,57 @@ export type DiagnoseInput = { undecryptableMergeReferences: ReadonlySet; }; -/** Contradictions where a checkout stage, processed payment, or merge - * reference points at a row that should also exist. */ -const referenceContradictions = ( - groups: readonly PaymentGroup[], +/** A `processed_payments` row is a handled terminal failure when its attendee + * is null but `failure_data` is set (refund/sold-out/price-change recorded for + * idempotent replay). Unlike an unresolved stuck reservation + * (attendee null + failure_data ''), a terminal failure is a normal stored + * outcome and must not block migration. */ +const isTerminalFailure = (row: ProcessedPaymentRow): boolean => + row.attendee_id === null && row.failure_data !== ""; + +/** Contradictions in one payment group: a checkout stage whose session has no + * processed payment, a stage whose own attendee has been deleted, or a + * non-terminal processed payment pointing at a missing attendee. A + * merge-reference row's `attendee_id` is the merge target (the source is + * deleted by `applyAttendeeMerge` in the same batch), so its existence is + * covered here — there is no separate "source attendee" expectation. */ +const groupContradictions = ( + group: PaymentGroup, attendeeIds: ReadonlySet, ): Contradiction[] => { const contradictions: Contradiction[] = []; - for (const group of groups) { - if (group.stage && !group.processed) { + if (group.stage) { + if (!group.processed) { contradictions.push({ detail: group.paymentSessionId, kind: "checkout_stage_without_processed_payment", }); } - if (group.processed) { - contradictions.push( - ...processedPaymentContradictions(group, attendeeIds), - ); + if (!attendeeIds.has(group.stage.attendee_id)) { + contradictions.push({ + detail: String(group.stage.attendee_id), + kind: "checkout_stage_without_attendee", + }); + } + } + if (group.processed && !isTerminalFailure(group.processed)) { + const { attendee_id: attendeeId } = group.processed; + if (attendeeId === null || !attendeeIds.has(attendeeId)) { + contradictions.push({ + detail: String(attendeeId), + kind: "processed_payment_without_attendee", + }); } } return contradictions; }; -const processedPaymentContradictions = ( - group: PaymentGroup, +/** Fold each payment group's reference contradictions into one list. */ +const referenceContradictions = ( + groups: readonly PaymentGroup[], attendeeIds: ReadonlySet, -): Contradiction[] => { - if (!group.processed) return []; - const { attendee_id: attendeeId, payment_session_id: sessionId } = - group.processed; - const contradictions: Contradiction[] = []; - if (attendeeId === null || !attendeeIds.has(attendeeId)) { - contradictions.push({ - detail: String(attendeeId), - kind: "processed_payment_without_attendee", - }); - } - if (!isMergeReference(sessionId)) return contradictions; - const sourceId = sourceAttendeeIdOf(sessionId); - if (!attendeeIds.has(sourceId)) { - contradictions.push({ - detail: sessionId, - kind: "merge_reference_without_source_attendee", - }); - } - if (attendeeId !== null && !attendeeIds.has(attendeeId)) { - contradictions.push({ - detail: sessionId, - kind: "merge_reference_without_target_attendee", - }); - } - return contradictions; -}; +): Contradiction[] => + groups.flatMap((group) => groupContradictions(group, attendeeIds)); const sumupContradictions = ( sumup: readonly SumupCheckoutRow[], @@ -334,8 +346,8 @@ const splitContradictions = ( /** Owner-key controls. When the key is supplied, every PII blob and * merge-reference charge that fails to decrypt is a contradiction. When it is - * not supplied and encrypted PII exists, the migration blocks instead of - * skipping the charges it cannot yet verify. */ + * not supplied and encrypted PII or encrypted merge-reference charges exist, + * the migration blocks instead of skipping the charges it cannot yet verify. */ const ownerKeyContradictions = (input: DiagnoseInput): Contradiction[] => { if (input.ownerKeyAvailable) { return [ @@ -349,10 +361,17 @@ const ownerKeyContradictions = (input: DiagnoseInput): Contradiction[] => { })), ]; } - if (input.attendees.some((attendee) => attendee.pii_blob !== "")) { + const piiCount = input.attendees.filter( + (attendee) => attendee.pii_blob !== "", + ).length; + const mergeCharges = hasEncryptedMergeReferenceCharge(input.processed); + if (piiCount > 0 || mergeCharges) { return [ { - detail: `${input.attendees.length} encrypted attendee PII blob(s) cannot be verified without the owner key`, + detail: + `${piiCount} encrypted attendee PII blob(s)` + + ` and ${mergeCharges ? "encrypted merge-reference charge(s)" : "no merge-reference charges"}` + + " cannot be verified without the owner key", kind: "owner_key_unavailable" as const, }, ]; @@ -400,12 +419,9 @@ export const diagnoseReadiness = (input: DiagnoseInput): ReadinessReport => { }; const CONTRADICTION_PHRASES: Record = { + checkout_stage_without_attendee: "checkout stage without a live attendee", checkout_stage_without_processed_payment: "checkout stage without a processed payment", - merge_reference_without_source_attendee: - "merge reference without its source attendee", - merge_reference_without_target_attendee: - "merge reference without its target attendee", owner_key_unavailable: "owner key not supplied", payment_split_across_page: "provider payment split across a page", processed_payment_without_attendee: diff --git a/test/integration/migration-verify.test.ts b/test/integration/migration-verify.test.ts index b4dfa879b2..a97976c814 100644 --- a/test/integration/migration-verify.test.ts +++ b/test/integration/migration-verify.test.ts @@ -36,15 +36,26 @@ const clip = (args: string[], promptResponse: string | null = null): Clip => ({ const run = (clip: Clip, pageSize = DEFAULT_PAGE_SIZE) => runMigrationVerifyCli({ args: clip.args, + createReader: () => createMigrationVerifyReader(pageSize), getEnv: () => undefined, ownerKey: createMigrationVerifyOwnerKey(), pageSize, prompt: () => clip.promptResponse, - reader: createMigrationVerifyReader(pageSize), stderr: (line) => clip.errors.push(line), stdout: (line) => clip.output.push(line), }); +/** Run with the test owner credentials and return the clip + result so each + * test asserts on the same possessor without restating the run boilerplate. */ +const runOwner = async ( + args: string[] = ["--owner", TEST_ADMIN_USERNAME], + password: string | null = TEST_ADMIN_PASSWORD, +): Promise<{ result: number; out: string; errors: string }> => { + const c = clip(args, password); + const result = await run(c); + return { errors: c.errors.join("\n"), out: c.output.join("\n"), result }; +}; + const seedAttendee = async (pii = true): Promise => { const blob = pii ? await encryptPiiBlob( @@ -216,10 +227,69 @@ describe("migration-verify production wiring", () => { ], ); - const c = clip(["--owner", TEST_ADMIN_USERNAME], TEST_ADMIN_PASSWORD); + const { out, result } = await runOwner(); + + expect(result).toBe(0); + expect(out).toContain("merge references: 1"); + }); + + test("does not block a terminal failure row (null attendee + failure_data set)", async () => { + await seedAttendee(); + await execute( + `INSERT INTO processed_payments (payment_session_id, attendee_id, processed_at, payment_reference, provider_refunded_at, failure_data) + VALUES (?, NULL, ?, '', '', ?)`, + ["failed-session", "2026-01-01T00:00:00.000Z", "enc:1:failure"], + ); + + const { out, result } = await runOwner(); + + expect(result).toBe(0); + expect(out).not.toContain("processed payment without a live attendee"); + }); + + test("blocks on an encrypted merge-reference charge when no owner key is supplied", async () => { + const target = await seedAttendee(false); + const encryptedRef = await encryptWithOwnerKey( + "pi_charge", + settings.publicKey, + ); + await execute( + `INSERT INTO processed_payments (payment_session_id, attendee_id, processed_at, payment_reference, provider_refunded_at) + VALUES (?, ?, ?, ?, '')`, + ["legacy-merge:99", target, "2026-01-01T00:00:00.000Z", encryptedRef], + ); + + const c = clip([]); // no --owner, no password const result = await run(c); + expect(result).toBe(1); + expect(c.output.join("\n")).toContain("owner key not supplied"); + }); + + test("passes the parsed --page-size to the database reader", async () => { + const attendeeId = await seedAttendee(); + await seedStage("p-0", attendeeId); + // Six processed rows, read at --page-size 2 → at least three keyset pages. + for (let i = 0; i < 6; i++) await seedProcessed(`p-${i}`, attendeeId); + + let seenPageSize = 0; + const c = clip(["--owner", TEST_ADMIN_USERNAME], TEST_ADMIN_PASSWORD); + const result = await runMigrationVerifyCli({ + args: c.args, + createReader: (pageSize) => { + seenPageSize = pageSize; + return createMigrationVerifyReader(pageSize); + }, + getEnv: () => undefined, + ownerKey: createMigrationVerifyOwnerKey(), + pageSize: DEFAULT_PAGE_SIZE, + prompt: () => c.promptResponse, + stderr: (line) => c.errors.push(line), + stdout: (line) => c.output.push(line), + }); + expect(result).toBe(0); - expect(c.output.join("\n")).toContain("merge references: 1"); + expect(seenPageSize).toBe(2); + expect(c.output.join("\n")).toContain("processed_payments rows: 6"); }); }); diff --git a/test/scripts/migration-verify.test.ts b/test/scripts/migration-verify.test.ts index d649e0119c..b1e2352f33 100644 --- a/test/scripts/migration-verify.test.ts +++ b/test/scripts/migration-verify.test.ts @@ -59,6 +59,7 @@ const fakeReader = ( Promise.resolve([ { attendee_id: 1, + failure_data: "", payment_reference: "", payment_session_id: "sess-1", processed_at: "2026-01-01T00:00:00.000Z", @@ -87,14 +88,37 @@ const alwaysVerifyingKey = ( }), }); +/** An owner key that derives from `derive` and records the verify call through + * `onVerify` (returning no failures). Shared by the tests that assert what the + * owner key was derived/verified with, so they differ only in that recording. */ +const recordingOwnerKey = ( + derive: (username: string, password: string) => Promise, + onVerify: ( + key: CryptoKey, + inputs: { + mergeReferences: readonly ProcessedPaymentRow[]; + attendees: readonly AttendeePiiSource[]; + }, + ) => void, +): MigrationVerifyOwnerKey => ({ + derive, + verify: (key, inputs) => { + onVerify(key, inputs); + return Promise.resolve({ + undecryptableMergeReferences: new Set(), + undecryptablePii: new Set(), + }); + }, +}); + const deps = ( clip: Clipio, over: Partial, ): MigrationVerifyDeps => ({ + createReader: () => fakeReader(), ownerKey: alwaysVerifyingKey({} as CryptoKey), pageSize: 500, prompt: () => "owner-password", - reader: fakeReader(), ...clip, ...over, }); @@ -102,7 +126,7 @@ const deps = ( describe("runMigrationVerifyCli", () => { test("prints usage and exits 0 for --help", async () => { const result = await runMigrationVerifyCli( - deps(io(["--help"]), { reader: fakeReader() }), + deps(io(["--help"]), { createReader: () => fakeReader() }), ); expect(result).toBe(0); }); @@ -123,8 +147,9 @@ describe("runMigrationVerifyCli", () => { const clip = io([]); const result = await runMigrationVerifyCli( deps(clip, { + createReader: () => + fakeReader({ readAttendeePii: () => Promise.resolve([]) }), prompt: () => null, - reader: fakeReader({ readAttendeePii: () => Promise.resolve([]) }), }), ); expect(result).toBe(0); @@ -140,19 +165,15 @@ describe("runMigrationVerifyCli", () => { let verifiedWith: CryptoKey | null = null; const result = await runMigrationVerifyCli( deps(clip, { - ownerKey: { - derive: (_username, password) => { - derivedWith = { password, username: _username }; + ownerKey: recordingOwnerKey( + (username, password) => { + derivedWith = { password, username }; return Promise.resolve(derived); }, - verify: (key) => { + (key) => { verifiedWith = key; - return Promise.resolve({ - undecryptableMergeReferences: new Set(), - undecryptablePii: new Set(), - }); }, - }, + ), }), ); expect(result).toBe(0); @@ -200,19 +221,20 @@ describe("runMigrationVerifyCli", () => { const clip = io([]); const result = await runMigrationVerifyCli( deps(clip, { + createReader: () => + fakeReader({ + readCheckoutStages: () => + Promise.resolve([ + { + attendee_id: 1, + created_at: "2026-01-01T00:00:00.000Z", + payment_session_id: "orphan", + provider: "stripe", + state: "completed", + }, + ]), + }), prompt: () => null, - reader: fakeReader({ - readCheckoutStages: () => - Promise.resolve([ - { - attendee_id: 1, - created_at: "2026-01-01T00:00:00.000Z", - payment_session_id: "orphan", - provider: "stripe", - state: "completed", - }, - ]), - }), }), ); expect(result).toBe(1); @@ -225,10 +247,11 @@ describe("runMigrationVerifyCli", () => { const clip = io([]); const result = await runMigrationVerifyCli( deps(clip, { + createReader: () => + fakeReader({ + readProcessedPayments: () => Promise.reject(new Error("DB down")), + }), prompt: () => null, - reader: fakeReader({ - readProcessedPayments: () => Promise.reject(new Error("DB down")), - }), }), ); expect(result).toBe(2); @@ -241,35 +264,34 @@ describe("runMigrationVerifyCli", () => { let verifiedRefs: readonly ProcessedPaymentRow[] = []; const result = await runMigrationVerifyCli( deps(clip, { - ownerKey: { - derive: () => Promise.resolve({} as CryptoKey), - verify: (_key, inputs) => { + createReader: () => + fakeReader({ + readProcessedPayments: () => + Promise.resolve([ + { + attendee_id: 1, + failure_data: "", + payment_reference: enc("hyb:1:charge"), + payment_session_id: ref, + processed_at: "2026-01-01T00:00:00.000Z", + provider_refunded_at: "", + }, + { + attendee_id: 1, + failure_data: "", + payment_reference: "", + payment_session_id: "sess-1", + processed_at: "2026-01-01T00:00:00.000Z", + provider_refunded_at: "", + }, + ]), + }), + ownerKey: recordingOwnerKey( + () => Promise.resolve({} as CryptoKey), + (_key, inputs) => { verifiedRefs = inputs.mergeReferences; - return Promise.resolve({ - undecryptableMergeReferences: new Set(), - undecryptablePii: new Set(), - }); }, - }, - reader: fakeReader({ - readProcessedPayments: () => - Promise.resolve([ - { - attendee_id: 1, - payment_reference: enc("hyb:1:charge"), - payment_session_id: ref, - processed_at: "2026-01-01T00:00:00.000Z", - provider_refunded_at: "", - }, - { - attendee_id: 1, - payment_reference: "", - payment_session_id: "sess-1", - processed_at: "2026-01-01T00:00:00.000Z", - provider_refunded_at: "", - }, - ]), - }), + ), }), ); expect(result).toBe(0); @@ -280,7 +302,7 @@ describe("runMigrationVerifyCli", () => { test("exits 2 and prints usage for an unknown flag", async () => { const clip = io(["--bogus"]); const result = await runMigrationVerifyCli( - deps(clip, { reader: fakeReader() }), + deps(clip, { createReader: () => fakeReader() }), ); expect(result).toBe(2); expect(clip.errors.join("\n")).toContain(MIGRATION_VERIFY_USAGE); diff --git a/test/shared/migration-readiness/readiness.test.ts b/test/shared/migration-readiness/readiness.test.ts index 851a1b8bdb..500cf7a1ed 100644 --- a/test/shared/migration-readiness/readiness.test.ts +++ b/test/shared/migration-readiness/readiness.test.ts @@ -27,6 +27,7 @@ const processed = ( over: Partial, ): ProcessedPaymentRow => ({ attendee_id: 1, + failure_data: "", payment_reference: "", payment_session_id: "sess-1", processed_at: "2026-01-01T00:00:00.000Z", @@ -54,6 +55,29 @@ const goodInput = (over: Partial = {}): DiagnoseInput => ({ ...over, }); +/** A no-owner-key input with one `legacy-merge:*` row on attendee 1 (no PII), + * varying only in whether the charge reference is encrypted or empty. Shared by + * the "blocks on encrypted merge charge" and "does not block on empty charge" + * cases so they differ in exactly the one fact under test. */ +const mergeRefNoOwnerInput = ( + ref: string, + charge: OwnerKeyEncrypted | "", +): DiagnoseInput => + goodInput({ + attendeeIds: new Set([1]), + attendees: [{ id: 1, pii_blob: "" }], + orderedProcessedSessionIds: [ref], + ownerKeyAvailable: false, + processed: [ + processed({ + attendee_id: 1, + payment_reference: charge, + payment_session_id: ref, + }), + ], + stages: [], + }); + describe("convertLegacyTimestamp", () => { test("canonicalises an ISO instant with an offset to …sssZ", () => { expect(convertLegacyTimestamp("2026-01-02T03:04:05+00:00")).toBe( @@ -152,7 +176,7 @@ describe("diagnoseReadiness", () => { }); }); - test("blocks when a processed payment has a null attendee id", () => { + test("blocks when a processed payment has a null attendee id (unresolved reservation)", () => { const report = diagnoseReadiness( goodInput({ processed: [processed({ attendee_id: null })] }), ); @@ -164,7 +188,42 @@ describe("diagnoseReadiness", () => { ).toBe(true); }); - test("blocks when a merged migration page is mistaken for end of input", () => { + test("does not block a terminal failure (null attendee + failure_data set)", () => { + const report = diagnoseReadiness( + goodInput({ + processed: [ + processed({ + attendee_id: null, + failure_data: "enc:1:failure" as never, + }), + ], + }), + ); + expect( + report.contradictions.some( + (c) => c.kind === "processed_payment_without_attendee", + ), + ).toBe(false); + }); + + test("blocks when a checkout stage points at a deleted attendee", () => { + const report = diagnoseReadiness( + goodInput({ + attendeeIds: new Set([1]), + stages: [stage({ attendee_id: 99 })], + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "99", + kind: "checkout_stage_without_attendee", + }); + }); + + test("a legitimate merge reference (source deleted, target live) does not block", () => { + // applyAttendeeMerge deletes the source attendee and writes + // legacy-merge: with attendee_id = target. The source id is + // historical, so its absence must NOT be a contradiction. const ref = `${LEGACY_MERGE_SESSION_PREFIX}5`; const report = diagnoseReadiness( goodInput({ @@ -180,15 +239,12 @@ describe("diagnoseReadiness", () => { ], }), ); - expect(report.kind).toBe("blocked"); expect(report.counts.mergeReferences).toBe(1); - expect(report.contradictions).toContainEqual({ - detail: ref, - kind: "merge_reference_without_source_attendee", - }); + expect(report.kind).toBe("ready"); + expect(report.contradictions).toEqual([]); }); - test("blocks when a merge reference target attendee is missing", () => { + test("blocks when a merge reference's target attendee is missing", () => { const ref = `${LEGACY_MERGE_SESSION_PREFIX}2`; const report = diagnoseReadiness( goodInput({ @@ -199,8 +255,8 @@ describe("diagnoseReadiness", () => { ); expect(report.kind).toBe("blocked"); expect(report.contradictions).toContainEqual({ - detail: ref, - kind: "merge_reference_without_target_attendee", + detail: "9", + kind: "processed_payment_without_attendee", }); }); @@ -209,11 +265,34 @@ describe("diagnoseReadiness", () => { expect(report.kind).toBe("blocked"); expect(report.contradictions).toContainEqual({ detail: - "1 encrypted attendee PII blob(s) cannot be verified without the owner key", + "1 encrypted attendee PII blob(s) and no merge-reference charges cannot be verified without the owner key", kind: "owner_key_unavailable", }); }); + test("blocks on encrypted merge-reference charges when no owner key is available, even with no PII", () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}2`; + const report = diagnoseReadiness( + mergeRefNoOwnerInput(ref, enc("hyb:1:charge")), + ); + expect(report.kind).toBe("blocked"); + expect( + report.contradictions.some((c) => c.kind === "owner_key_unavailable"), + ).toBe(true); + expect( + report.contradictions.find((c) => c.kind === "owner_key_unavailable") + ?.detail, + ).toContain("encrypted merge-reference charge(s)"); + }); + + test("does not block on a merge reference when the charge is empty and no owner key is supplied", () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}2`; + const report = diagnoseReadiness(mergeRefNoOwnerInput(ref, "")); + expect( + report.contradictions.some((c) => c.kind === "owner_key_unavailable"), + ).toBe(false); + }); + test("does not report a missing owner key when there is no PII to check", () => { const report = diagnoseReadiness( goodInput({ @@ -358,7 +437,7 @@ describe("formatReadinessReport", () => { " not supplied — 1 attendee PII blob(s) cannot be verified", "", "Contradictions", - " - owner key not supplied: 1 encrypted attendee PII blob(s) cannot be verified without the owner key", + " - owner key not supplied: 1 encrypted attendee PII blob(s) and no merge-reference charges cannot be verified without the owner key", ]); }); @@ -395,7 +474,7 @@ describe("formatReadinessReport", () => { }), processed({ payment_session_id: "sess-1" }), ], - stages: [stage({ payment_session_id: "orphan" })], + stages: [stage({ attendee_id: 66, payment_session_id: "orphan" })], sumup: [ { created_at: "2026-01-01T00:00:00.000Z", @@ -411,13 +490,8 @@ describe("formatReadinessReport", () => { expect(out).toContain( " - checkout stage without a processed payment: orphan", ); + expect(out).toContain(" - checkout stage without a live attendee: 66"); expect(out).toContain(" - processed payment without a live attendee: 88"); - expect(out).toContain( - " - merge reference without its source attendee: legacy-merge:99", - ); - expect(out).toContain( - " - merge reference without its target attendee: legacy-merge:99", - ); expect(out).toContain(" - provider payment split across a page: x"); expect(out).toContain(" - sumup checkout without a recorded id: idx"); expect(out).toContain(" - attendee PII that did not decrypt: attendee 7"); From 10ac3c4c07bd3568df5ee4a8cb71f2b00e127235 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 9 Aug 2026 15:49:08 +0000 Subject: [PATCH 4/7] Address Codex round 2: PII hybrid/parse, owner-only derive, all charge refs, timestamps, servicing, dead-split removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven more Codex P2 findings, all verified and fixed: 1. (deps) A non-empty pii_blob that is not hybrid ciphertext is no longer counted as verified — decryptPiiBlob is used (decrypt AND parse), so a corrupt-plaintext or malformed-JSON blob fails readiness. 2. (readiness) convertLegacyTimestamp now catches epoch-millis values outside Date's representable range (returns null instead of throwing). Required timestamps (processed_at, checkout_stages.created_at, sumup_checkouts. created_at) that are empty now contradict; only provider_refunded_at is optional. 3. (deps) --owner now requires an owner-level account: derive returns null for any non-owner (manager/agent/editor) carrying the site data key, so a non-owner cannot make the verifier decrypt PII. 4. (lib) Every processed_payments row with a non-empty payment_reference is verified (not only legacy-merge handoffs); a corrupt regular captured charge is reported as undecryptable_payment_reference. 5. (deps) PII blobs are parsed through the real PII schema after decrypt, so malformed-but-decryptable blobs (missing payment_id etc.) fail readiness. 6. (readiness) Removed the dead paymentsExceedingPage / payment_split_across_page check: payment_session_id is a primary key, so within one table a payment appears at most once and a keyset page can never split it (split detection is a PR 14 copy-cursor concern, not this read-only verifier). 7. (deps) readAttendeeIds filters kind = 'attendee', so a payment pointing at a servicing (van/crew) row blocks instead of reading as a valid attendee. readiness.ts mutation 100% (127/127, no survivors, no suppressions needed). --- scripts/migration-verify-deps.ts | 59 +++++-- scripts/migration-verify-lib.ts | 47 +++--- .../equivalent-mutants/shared-m-z.txt | 4 - src/shared/migration-readiness/readiness.ts | 158 +++++++++--------- test/integration/migration-verify.test.ts | 94 ++++++++++- test/scripts/migration-verify.test.ts | 10 +- .../migration-readiness/readiness.test.ts | 97 ++++++++--- 7 files changed, 311 insertions(+), 158 deletions(-) diff --git a/scripts/migration-verify-deps.ts b/scripts/migration-verify-deps.ts index a17f7b5960..7a821a0d41 100644 --- a/scripts/migration-verify-deps.ts +++ b/scripts/migration-verify-deps.ts @@ -25,9 +25,15 @@ import { privateKeyFromDataKey, } from "#shared/crypto/owner-kek.ts"; import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { ATTENDEE_KIND } from "#shared/db/attendees/kind.ts"; +import { decryptPiiBlob } from "#shared/db/attendees/pii.ts"; import { queryAll } from "#shared/db/client.ts"; import { settings } from "#shared/db/settings.ts"; -import { getUserByUsername, verifyUserPassword } from "#shared/db/users.ts"; +import { + decryptAdminLevel, + getUserByUsername, + verifyUserPassword, +} from "#shared/db/users.ts"; import type { AttendeePiiSource, CheckoutStageRow, @@ -78,9 +84,11 @@ export const createMigrationVerifyReader = ( pageSize: number = DEFAULT_VERIFY_PAGE_SIZE, ): MigrationVerifyReader => ({ readAttendeeIds: () => { + // Only real attendees hold payments; servicing rows (vans/crews) are never + // valid payment targets, so exclude them from the live-attendee set. const ids = keysetRows<{ id: number }>( "SELECT id FROM attendees", - null, + `kind = '${ATTENDEE_KIND}'`, "id", pageSize, ); @@ -116,14 +124,16 @@ export const createMigrationVerifyReader = ( ), }); -/** Whether an owner-key-encrypted value decrypts under the key. An empty or - * legacy plaintext value is treated as decryptable (nothing to verify); a - * hybrid ciphertext that throws on decrypt is not. Returns no plaintext. */ -const decryptsUnderOwnerKey = async ( +/** Whether an owner-key-encrypted payment reference decrypts under the key. An + * empty value is nothing to verify. A non-hybrid value is a legacy plaintext + * payment_reference (development builds wrote the column in the clear — see + * `payment-references.ts`), so it is treated as decryptable. A hybrid + * ciphertext that throws on decrypt is not. Returns no plaintext. */ +const paymentReferenceDecrypts = async ( value: OwnerKeyEncrypted | "", key: CryptoKey, ): Promise => { - if (!value.startsWith(HYBRID_PREFIX)) return true; + if (value === "" || !value.startsWith(HYBRID_PREFIX)) return true; try { await decryptWithOwnerKey(value as OwnerKeyEncrypted, key); return true; @@ -134,11 +144,12 @@ const decryptsUnderOwnerKey = async ( /** * The owner-key provider: an owner-authenticated step that derives the site - * private key from the owner password, then proves it can decrypt every - * attendee PII blob and merge-reference charge. A wrong password, a missing - * wrapped-data key, or an absent wrapped private key returns null — the caller - * then blocks rather than skipping the encrypted charges. PII plaintext never - * leaves this step; only ids/keys that failed are returned. + * private key from an owner password, then proves it can decrypt (and parse) + * every attendee PII blob and every payment reference. A wrong password, a + * non-owner account, a missing wrapped-data key, or an absent wrapped private + * key returns null — the caller then blocks rather than skipping the encrypted + * charges. PII plaintext never leaves this step; only ids/keys that failed are + * returned. */ export const createMigrationVerifyOwnerKey = (): MigrationVerifyOwnerKey => ({ derive: async (username, password) => { @@ -146,6 +157,10 @@ export const createMigrationVerifyOwnerKey = (): MigrationVerifyOwnerKey => ({ if (!user?.wrapped_data_key) return null; const passwordHash = await verifyUserPassword(user, password); if (!passwordHash) return null; + // The private key protects attendee PII for the whole site, so only an + // owner-level account may derive it through this command. + const adminLevel = await decryptAdminLevel(user); + if (adminLevel !== "owner") return null; await settings.loadKeys([CONFIG_KEYS.WRAPPED_PRIVATE_KEY]); if (!settings.wrappedPrivateKey) return null; const kek = await deriveOwnerKek(password, passwordHash, user.kek_version); @@ -154,19 +169,27 @@ export const createMigrationVerifyOwnerKey = (): MigrationVerifyOwnerKey => ({ }, verify: async (key, inputs) => { const undecryptablePii = new Set(); - const undecryptableMergeReferences = new Set(); + const undecryptablePaymentReferences = new Set(); for (const { id, pii_blob } of inputs.attendees) { - if (!(await decryptsUnderOwnerKey(pii_blob, key))) + if (pii_blob === "") continue; + try { + // Decrypt AND parse: a blob that decrypts to malformed JSON or one + // missing required PII fields would fail the real attendee readers, so + // it must fail readiness too. Non-hybrid blobs throw here (PII has no + // legacy plaintext fallback), catching corrupt plaintext PII. + await decryptPiiBlob(pii_blob as OwnerKeyEncrypted, key, true); + } catch { undecryptablePii.add(id); + } } for (const { payment_reference, payment_session_id, - } of inputs.mergeReferences) { - if (!(await decryptsUnderOwnerKey(payment_reference, key))) { - undecryptableMergeReferences.add(payment_session_id); + } of inputs.paymentReferences) { + if (!(await paymentReferenceDecrypts(payment_reference, key))) { + undecryptablePaymentReferences.add(payment_session_id); } } - return { undecryptableMergeReferences, undecryptablePii }; + return { undecryptablePaymentReferences, undecryptablePii }; }, }); diff --git a/scripts/migration-verify-lib.ts b/scripts/migration-verify-lib.ts index 91fce3a29e..9ecf850895 100644 --- a/scripts/migration-verify-lib.ts +++ b/scripts/migration-verify-lib.ts @@ -21,7 +21,6 @@ import { type CheckoutStageRow, diagnoseReadiness, formatReadinessReport, - LEGACY_MERGE_SESSION_PREFIX, type ProcessedPaymentRow, type SumupCheckoutRow, } from "#shared/migration-readiness/readiness.ts"; @@ -42,25 +41,28 @@ export interface MigrationVerifyReader { } /** The encrypted sources the owner key verifies: every attendee PII blob and - * every merge-reference charge reference. Bundled so the verify contract and - * the assessor that forwards to it share one parameter shape. */ + * every `processed_payments` row carrying a `payment_reference` (a captured + * charge — regular or merge-reference). Bundled so the verify contract and the + * assessor that forwards to it share one parameter shape. */ export interface MigrationVerifyOwnerKeyInputs { attendees: readonly AttendeePiiSource[]; - mergeReferences: readonly ProcessedPaymentRow[]; + paymentReferences: readonly ProcessedPaymentRow[]; } export interface MigrationVerifyOwnerKey { /** Derive the owner private key from an owner-authenticated password, or null - * when the password is wrong or the key cannot be unwrapped. */ + * when the password is wrong, the account is not an owner, or the key cannot + * be unwrapped. */ derive(username: string, password: string): Promise; - /** Decrypt every attendee PII blob and every merge-reference charge reference - * under the owner key, returning the ids/keys that failed (never plaintext). */ + /** Decrypt (and for PII, parse) every attendee PII blob and every payment + * reference under the owner key, returning the ids/keys that failed (never + * plaintext). */ verify( key: CryptoKey, inputs: MigrationVerifyOwnerKeyInputs, ): Promise<{ undecryptablePii: Set; - undecryptableMergeReferences: Set; + undecryptablePaymentReferences: Set; }>; } @@ -74,9 +76,6 @@ export interface MigrationVerifyDeps extends ScriptIo { prompt: (message: string) => string | null; } -const isMergeReference = (sessionId: string): boolean => - sessionId.startsWith(LEGACY_MERGE_SESSION_PREFIX); - interface ParsedArgs { help: boolean; owner: string | undefined; @@ -125,8 +124,7 @@ const readAllSources = async ( attendees: AttendeePiiSource[]; attendeeIds: Set; checkoutStages: CheckoutStageRow[]; - mergeReferences: ProcessedPaymentRow[]; - orderedProcessedSessionIds: string[]; + paymentReferences: ProcessedPaymentRow[]; processed: ProcessedPaymentRow[]; sumup: SumupCheckoutRow[]; }> => { @@ -138,15 +136,18 @@ const readAllSources = async ( reader.readAttendeePii(), reader.readAttendeeIds(), ]); - const mergeReferences = processed.filter((row) => - isMergeReference(row.payment_session_id), + // Every row carrying a payment_reference (a captured charge) is verified by + // the owner key — not only merge-reference handoffs. A corrupt regular + // charge reference would break the refund-target migration, so it must fail + // readiness now. + const paymentReferences = processed.filter( + (row) => row.payment_reference !== "", ); return { attendeeIds, attendees, checkoutStages, - mergeReferences, - orderedProcessedSessionIds: processed.map((row) => row.payment_session_id), + paymentReferences, processed, sumup, }; @@ -159,11 +160,11 @@ const assessOwnerKey = async ( ): Promise<{ ownerKeyAvailable: boolean; undecryptablePii: Set; - undecryptableMergeReferences: Set; + undecryptablePaymentReferences: Set; }> => { const empty = { ownerKeyAvailable: false, - undecryptableMergeReferences: new Set(), + undecryptablePaymentReferences: new Set(), undecryptablePii: new Set(), }; if (!owner) return empty; @@ -172,7 +173,7 @@ const assessOwnerKey = async ( const key = await deps.ownerKey.derive(owner, password); if (key === null) { deps.stderr( - "The owner private key could not be derived from that password. Attendee PII cannot be verified.", + "The owner private key could not be derived from that password (wrong password, or not an owner account). Attendee PII cannot be verified.", ); return empty; } @@ -209,19 +210,17 @@ export const runMigrationVerifyCli = async ( const ownerKey = await assessOwnerKey(deps, parsed.value.owner, { attendees: sources.attendees, - mergeReferences: sources.mergeReferences, + paymentReferences: sources.paymentReferences, }); const report = diagnoseReadiness({ attendeeIds: sources.attendeeIds, attendees: sources.attendees, - orderedProcessedSessionIds: sources.orderedProcessedSessionIds, ownerKeyAvailable: ownerKey.ownerKeyAvailable, - pageSize: parsed.value.pageSize, processed: sources.processed, stages: sources.checkoutStages, sumup: sources.sumup, - undecryptableMergeReferences: ownerKey.undecryptableMergeReferences, + undecryptablePaymentReferences: ownerKey.undecryptablePaymentReferences, undecryptablePii: ownerKey.undecryptablePii, }); diff --git a/scripts/mutation/equivalent-mutants/shared-m-z.txt b/scripts/mutation/equivalent-mutants/shared-m-z.txt index 6e96a2679e..689a8ece40 100644 --- a/scripts/mutation/equivalent-mutants/shared-m-z.txt +++ b/scripts/mutation/equivalent-mutants/shared-m-z.txt @@ -260,7 +260,3 @@ src/shared/payment-signature.ts::canonicalPricePayload.entries~0kqbt3q 1 → 0 src/shared/payment/money.ts::CurrencySchema~1qa2mi1 Currency must be three uppercase letters → "" # message text of the currency regex; validation outcome is identical src/shared/payment/resource-id.ts::ResourceIdSchema~1pmki6u Resource id must be text with no whitespace → "" # message text of the resource-id regex; validation outcome is identical -# paymentsExceedingPage: counts.get(id) is undefined | number; for 0, undefined, -# or any number, `?? 0` and `|| 0` yield the same value, so the two operators -# cannot be distinguished. -src/shared/migration-readiness/readiness.ts::paymentsExceedingPage~0643qpn ?? → || # counts.get(id) is undefined | number; 0 ?? 0 == 0 || 0 == 0 diff --git a/src/shared/migration-readiness/readiness.ts b/src/shared/migration-readiness/readiness.ts index eb401e18b2..259ffdf00b 100644 --- a/src/shared/migration-readiness/readiness.ts +++ b/src/shared/migration-readiness/readiness.ts @@ -81,8 +81,8 @@ export type ContradictionKind = | "checkout_stage_without_processed_payment" | "checkout_stage_without_attendee" | "processed_payment_without_attendee" - | "payment_split_across_page" | "undecryptable_attendee_pii" + | "undecryptable_payment_reference" | "undecryptable_merge_reference" | "owner_key_unavailable" | "unconvertible_timestamp" @@ -116,15 +116,22 @@ export type ReadinessReport = { /** Normalise a legacy stored timestamp to the canonical `…sssZ` ISO instant. * Accepts ISO-8601 instants (any offset or sub-second precision) and the * whole-epoch-millis strings older rows stored. Returns `""` for an empty - * column (a genuinely absent time is not a contradiction) and `null` when a - * value is neither a real instant nor epoch-millis, so the caller can surface - * it instead of inventing a moment. */ + * column (a genuinely absent time is handled by the caller, which knows + * whether the column is required) and `null` when a value is neither a real + * instant nor a representable epoch-millis, so the caller can surface it + * instead of inventing a moment. */ export const convertLegacyTimestamp = (value: string): string | null => { if (value === "") return ""; if (/^\d+$/.test(value)) { const epoch = Number(value); - if (Number.isInteger(epoch) && epoch > 0) return epochMsToIso(epoch); - return null; + if (!Number.isInteger(epoch) || epoch <= 0) return null; + try { + return epochMsToIso(epoch); + } catch { + // Values outside Date's representable range (e.g. an overflowed epoch) + // are not real instants — surface them rather than throwing. + return null; + } } try { // Temporal rejects impossible dates (month 13, Feb 30) where Date would @@ -139,20 +146,20 @@ export const convertLegacyTimestamp = (value: string): string | null => { const isMergeReference = (sessionId: string): boolean => sessionId.startsWith(LEGACY_MERGE_SESSION_PREFIX); -/** A merge-reference charge that carries an encrypted `payment_reference` — one - * only the owner key can verify. Used to block the migration when the key is +/** A `processed_payments` row that carries an owner-key-encrypted + * `payment_reference` — a captured charge (regular or merge-reference) only + * the owner key can verify. Used to block the migration when the key is * unavailable, instead of silently skipping the charge. */ -const hasEncryptedMergeReferenceCharge = ( +const hasEncryptedPaymentReference = ( processed: readonly ProcessedPaymentRow[], -): boolean => - processed.some( - (row) => - isMergeReference(row.payment_session_id) && row.payment_reference !== "", - ); +): boolean => processed.some((row) => row.payment_reference !== ""); /** Convert every legacy timestamp column to a canonical instant, returning one - * contradiction per value that is neither a real instant nor epoch-millis. - * Empty columns are left as empty (an absent time is not a contradiction). */ + * contradiction per value that is neither a real instant nor a representable + * epoch-millis. `processed_at`, `checkout_stages.created_at`, and + * `sumup_checkouts.created_at` are NOT NULL in the schema, so an empty value + * is corruption; `provider_refunded_at` is optional (empty means "no refund + * yet") so an empty value is not a contradiction. */ const convertAllTimestamps = ( processed: readonly ProcessedPaymentRow[], stages: readonly CheckoutStageRow[], @@ -160,14 +167,20 @@ const convertAllTimestamps = ( ): { contradictions: Contradiction[]; converted: number } => { const contradictions: Contradiction[] = []; let converted = 0; - const check = (value: string, label: string): void => { + const check = (value: string, label: string, required: boolean): void => { + if (value === "") { + if (required) { + contradictions.push({ detail: label, kind: "unconvertible_timestamp" }); + } + return; + } + // value is non-empty here, so convertLegacyTimestamp returns either a + // canonical ISO instant or null — never "" — so a non-null result is a + // successful conversion (no "" literal to distinguish against). const result = convertLegacyTimestamp(value); if (result === null) { - contradictions.push({ - detail: label, - kind: "unconvertible_timestamp", - }); - } else if (result !== "") { + contradictions.push({ detail: label, kind: "unconvertible_timestamp" }); + } else { converted += 1; } }; @@ -175,17 +188,27 @@ const convertAllTimestamps = ( check( row.processed_at, `processed_payments.processed_at = ${row.processed_at}`, + true, ); check( row.provider_refunded_at, `processed_payments.provider_refunded_at = ${row.provider_refunded_at}`, + false, ); } for (const row of stages) { - check(row.created_at, `checkout_stages.created_at = ${row.created_at}`); + check( + row.created_at, + `checkout_stages.created_at = ${row.created_at}`, + true, + ); } for (const row of sumup) { - check(row.created_at, `sumup_checkouts.created_at = ${row.created_at}`); + check( + row.created_at, + `sumup_checkouts.created_at = ${row.created_at}`, + true, + ); } return { contradictions, converted }; }; @@ -232,23 +255,6 @@ export const buildPaymentGroups = ( return order.map((sessionId) => bySession.get(sessionId)!); }; -/** Provider payments whose rows would not fit on one keyset page, so a cursor - * that pages by row count would split one payment across a boundary. Each - * session id is returned once. With the legacy tables each payment is one row - * per table, so this only fires once a single payment grows past the page. */ -export const paymentsExceedingPage = ( - orderedSessionIds: readonly string[], - pageSize: number, -): readonly string[] => { - const counts = new Map(); - for (const id of orderedSessionIds) { - counts.set(id, (counts.get(id) ?? 0) + 1); - } - return [...counts.entries()] - .filter(([, count]) => count > pageSize) - .map(([id]) => id); -}; - /** Inputs to the readiness verdict. The caller fetches every row and reports * any owner-key decryption failures; these rules never perform IO. */ export type DiagnoseInput = { @@ -256,21 +262,20 @@ export type DiagnoseInput = { stages: readonly CheckoutStageRow[]; sumup: readonly SumupCheckoutRow[]; attendees: readonly AttendeePiiSource[]; - /** Live attendee ids, used to prove processed-payment and merge-reference rows + /** Live attendee ids, used to prove processed-payment and checkout-stage rows * still point at real attendees rather than deleted bookings. */ attendeeIds: ReadonlySet; - /** `payment_session_id` rows of `processed_payments` in read order, used to - * prove a cursor never splits one provider payment across a keyset page. */ - orderedProcessedSessionIds: readonly string[]; - pageSize: number; /** Whether the caller supplied the owner private key and decrypted PII. When - * false and encrypted PII exists, the verdict blocks rather than skipping. */ + * false and encrypted PII or payment references exist, the verdict blocks + * rather than skipping. */ ownerKeyAvailable: boolean; - /** Attendee ids whose `pii_blob` failed to decrypt under the owner key. */ + /** Attendee ids whose `pii_blob` failed to decrypt (or parse) under the + * owner key. */ undecryptablePii: ReadonlySet; - /** `payment_session_id`s of merge-reference rows whose `payment_reference` - * failed to decrypt under the owner key. */ - undecryptableMergeReferences: ReadonlySet; + /** `payment_session_id`s of `processed_payments` rows whose + * `payment_reference` failed to decrypt under the owner key (regular + * captured charges and merge-reference charges alike). */ + undecryptablePaymentReferences: ReadonlySet; }; /** A `processed_payments` row is a handled terminal failure when its attendee @@ -335,42 +340,40 @@ const sumupContradictions = ( kind: "sumup_checkout_without_id" as const, })); -const splitContradictions = ( - orderedSessionIds: readonly string[], - pageSize: number, -): Contradiction[] => - paymentsExceedingPage(orderedSessionIds, pageSize).map((id) => ({ - detail: id, - kind: "payment_split_across_page" as const, - })); - -/** Owner-key controls. When the key is supplied, every PII blob and - * merge-reference charge that fails to decrypt is a contradiction. When it is - * not supplied and encrypted PII or encrypted merge-reference charges exist, - * the migration blocks instead of skipping the charges it cannot yet verify. */ +/** Owner-key controls. When the key is supplied, every PII blob and every + * payment reference that fails to decrypt is a contradiction (classified as a + * merge-reference charge or a regular captured charge by the session id). When + * the key is not supplied and encrypted PII or encrypted payment references + * exist, the migration blocks instead of skipping the charges it cannot yet + * verify. */ const ownerKeyContradictions = (input: DiagnoseInput): Contradiction[] => { if (input.ownerKeyAvailable) { + const paymentRefFailures = [...input.undecryptablePaymentReferences].map( + (sessionId) => ({ + detail: sessionId, + kind: (isMergeReference(sessionId) + ? "undecryptable_merge_reference" + : "undecryptable_payment_reference") as ContradictionKind, + }), + ); return [ ...[...input.undecryptablePii].map((attendeeId) => ({ detail: `attendee ${attendeeId}`, kind: "undecryptable_attendee_pii" as const, })), - ...[...input.undecryptableMergeReferences].map((sessionId) => ({ - detail: sessionId, - kind: "undecryptable_merge_reference" as const, - })), + ...paymentRefFailures, ]; } const piiCount = input.attendees.filter( (attendee) => attendee.pii_blob !== "", ).length; - const mergeCharges = hasEncryptedMergeReferenceCharge(input.processed); - if (piiCount > 0 || mergeCharges) { + const hasCharges = hasEncryptedPaymentReference(input.processed); + if (piiCount > 0 || hasCharges) { return [ { detail: `${piiCount} encrypted attendee PII blob(s)` + - ` and ${mergeCharges ? "encrypted merge-reference charge(s)" : "no merge-reference charges"}` + + ` and ${hasCharges ? "encrypted payment reference(s)" : "no payment references"}` + " cannot be verified without the owner key", kind: "owner_key_unavailable" as const, }, @@ -381,10 +384,10 @@ const ownerKeyContradictions = (input: DiagnoseInput): Contradiction[] => { /** Turn one lossless read of the legacy payment sources into a readiness * verdict. The data is `ready` only when every row is accounted for, every - * timestamp normalises, every payment stays inside one page, every reference - * points at a live attendee, and the owner key could decrypt every PII blob - * and merge-reference charge. Any single finding blocks the migration so the - * operator fixes it before a later release changes payment history. */ + * timestamp normalises, every reference points at a live attendee, and the + * owner key could decrypt every PII blob and payment reference. Any single + * finding blocks the migration so the operator fixes it before a later release + * changes payment history. */ export const diagnoseReadiness = (input: DiagnoseInput): ReadinessReport => { const groups = buildPaymentGroups(input.processed, input.stages); const timestamp = convertAllTimestamps( @@ -395,7 +398,6 @@ export const diagnoseReadiness = (input: DiagnoseInput): ReadinessReport => { const contradictions: Contradiction[] = [ ...referenceContradictions(groups, input.attendeeIds), ...sumupContradictions(input.sumup), - ...splitContradictions(input.orderedProcessedSessionIds, input.pageSize), ...timestamp.contradictions, ...ownerKeyContradictions(input), ]; @@ -417,19 +419,19 @@ export const diagnoseReadiness = (input: DiagnoseInput): ReadinessReport => { kind: contradictions.length === 0 ? "ready" : "blocked", }; }; - const CONTRADICTION_PHRASES: Record = { checkout_stage_without_attendee: "checkout stage without a live attendee", checkout_stage_without_processed_payment: "checkout stage without a processed payment", owner_key_unavailable: "owner key not supplied", - payment_split_across_page: "provider payment split across a page", processed_payment_without_attendee: "processed payment without a live attendee", sumup_checkout_without_id: "sumup checkout without a recorded id", unconvertible_timestamp: "timestamp that cannot be converted", undecryptable_attendee_pii: "attendee PII that did not decrypt", undecryptable_merge_reference: "merge-reference charge that did not decrypt", + undecryptable_payment_reference: + "captured charge reference that did not decrypt", }; /** Render a readiness verdict as plain operator lines. The owner-key line says diff --git a/test/integration/migration-verify.test.ts b/test/integration/migration-verify.test.ts index a97976c814..371e66fe95 100644 --- a/test/integration/migration-verify.test.ts +++ b/test/integration/migration-verify.test.ts @@ -5,10 +5,13 @@ import { createMigrationVerifyReader, } from "#scripts/migration-verify-deps.ts"; import { runMigrationVerifyCli } from "#scripts/migration-verify-lib.ts"; +import { encrypt as envEncrypt } from "#shared/crypto/encryption.ts"; +import { hmacHash } from "#shared/crypto/hashing.ts"; import { encryptWithOwnerKey } from "#shared/crypto/keys.ts"; import { buildPiiBlob, encryptPiiBlob } from "#shared/db/attendees/pii.ts"; -import { execute } from "#shared/db/client.ts"; +import { execute, queryOne } from "#shared/db/client.ts"; import { settings } from "#shared/db/settings.ts"; +import { invalidateUsersCache } from "#shared/db/users.ts"; import { nowIso } from "#shared/now.ts"; import { CONFIG_KEYS } from "#shared/settings/keys.ts"; import { createTestDbWithSetup, resetDb } from "#test-utils/db.ts"; @@ -113,6 +116,16 @@ const seedConsistentPayment = async ( await seedStage(sessionId, attendeeId); }; +/** Assert the run refused to derive the owner key: exit 1, a "could not be + * derived" stderr message, and an "owner key not supplied" report line. Shared + * by the wrong-password, unknown-username, absent-private-key, and non-owner + * cases so their assertion blocks differ only in setup. */ +const expectOwnerKeyBlocked = (c: Clip, result: number): void => { + expect(result).toBe(1); + expect(c.errors.join("\n")).toContain("could not be derived"); + expect(c.output.join("\n")).toContain("owner key not supplied"); +}; + describe("migration-verify production wiring", () => { beforeEach(async () => { await createTestDbWithSetup(); @@ -157,11 +170,7 @@ describe("migration-verify production wiring", () => { await seedConsistentPayment("sess-1", attendeeId); const c = clip(["--owner", TEST_ADMIN_USERNAME], "the-wrong-password"); - const result = await run(c); - - expect(result).toBe(1); - expect(c.errors.join("\n")).toContain("could not be derived"); - expect(c.output.join("\n")).toContain("owner key not supplied"); + expectOwnerKeyBlocked(c, await run(c)); }); test("blocks when the owner username is unknown", async () => { @@ -292,4 +301,77 @@ describe("migration-verify production wiring", () => { expect(seenPageSize).toBe(2); expect(c.output.join("\n")).toContain("processed_payments rows: 6"); }); + + test("blocks when --owner is a non-owner account carrying the site data key", async () => { + // Seed an attendee with PII so a refused owner key actually blocks (a null + // derive with nothing encrypted would otherwise read as ready). + await seedAttendee(); + // Seed a manager-level user that reuses the owner's password hash and + // wrapped data key (as invite acceptance would), so the owner password + // verifies but the admin level is not "owner" — derive must refuse. + const owner = await queryOne<{ + password_hash: string; + wrapped_data_key: string; + kek_version: number; + }>( + "SELECT password_hash, wrapped_data_key, kek_version FROM users WHERE username_index = ?", + [await hmacHash(TEST_ADMIN_USERNAME)], + ); + await execute( + `INSERT INTO users + (username_hash, username_index, password_hash, wrapped_data_key, admin_level, invite_code_hash, invite_expiry, kek_version) + VALUES (?, ?, ?, ?, ?, '', '', ?)`, + [ + await envEncrypt("manager"), + await hmacHash("manager"), + owner!.password_hash, + owner!.wrapped_data_key, + await envEncrypt("manager"), + owner!.kek_version, + ], + ); + invalidateUsersCache(); + + const c = clip(["--owner", "manager"], TEST_ADMIN_PASSWORD); + expectOwnerKeyBlocked(c, await run(c)); + }); + + test("blocks on a corrupt regular (non-merge) captured charge reference", async () => { + const attendeeId = await seedAttendee(); + await seedStage("sess-1", attendeeId); + await seedProcessed("sess-1", attendeeId); + // A regular processed_payments row whose payment_reference is corrupt + // hybrid ciphertext — would break the refund-target migration, so it must + // fail readiness now (not only merge-reference handoffs are verified). + await execute( + `UPDATE processed_payments SET payment_reference = 'hyb:1:corrupt' + WHERE payment_session_id = 'sess-1'`, + ); + + const { out, result } = await runOwner(); + + expect(result).toBe(1); + expect(out).toContain( + "captured charge reference that did not decrypt: sess-1", + ); + }); + + test("does not treat a servicing row as a valid payment attendee", async () => { + // Seed a servicing (van/crew) attendee and point a processed payment at it. + // Servicing rows are not real payment targets, so readiness must block. + await execute( + "INSERT INTO attendees (created, kind, pii_blob) VALUES (?, 'servicing', '')", + [nowIso()], + ); + const servicing = await queryOne<{ id: number }>( + "SELECT id FROM attendees WHERE kind = 'servicing' LIMIT 1", + ); + await seedStage("sess-1", servicing!.id); + await seedProcessed("sess-1", servicing!.id); + + const { out, result } = await runOwner(["--owner", TEST_ADMIN_USERNAME]); + + expect(result).toBe(1); + expect(out).toContain("processed payment without a live attendee"); + }); }); diff --git a/test/scripts/migration-verify.test.ts b/test/scripts/migration-verify.test.ts index b1e2352f33..dd5fb78ca6 100644 --- a/test/scripts/migration-verify.test.ts +++ b/test/scripts/migration-verify.test.ts @@ -83,7 +83,7 @@ const alwaysVerifyingKey = ( derive: () => Promise.resolve(key), verify: () => Promise.resolve({ - undecryptableMergeReferences: new Set(), + undecryptablePaymentReferences: new Set(), undecryptablePii: new Set(), }), }); @@ -96,7 +96,7 @@ const recordingOwnerKey = ( onVerify: ( key: CryptoKey, inputs: { - mergeReferences: readonly ProcessedPaymentRow[]; + paymentReferences: readonly ProcessedPaymentRow[]; attendees: readonly AttendeePiiSource[]; }, ) => void, @@ -105,7 +105,7 @@ const recordingOwnerKey = ( verify: (key, inputs) => { onVerify(key, inputs); return Promise.resolve({ - undecryptableMergeReferences: new Set(), + undecryptablePaymentReferences: new Set(), undecryptablePii: new Set(), }); }, @@ -205,7 +205,7 @@ describe("runMigrationVerifyCli", () => { derive: () => Promise.resolve({} as CryptoKey), verify: () => Promise.resolve({ - undecryptableMergeReferences: new Set(), + undecryptablePaymentReferences: new Set(), undecryptablePii: new Set([1]), }), }, @@ -289,7 +289,7 @@ describe("runMigrationVerifyCli", () => { ownerKey: recordingOwnerKey( () => Promise.resolve({} as CryptoKey), (_key, inputs) => { - verifiedRefs = inputs.mergeReferences; + verifiedRefs = inputs.paymentReferences; }, ), }), diff --git a/test/shared/migration-readiness/readiness.test.ts b/test/shared/migration-readiness/readiness.test.ts index 500cf7a1ed..68abd735ff 100644 --- a/test/shared/migration-readiness/readiness.test.ts +++ b/test/shared/migration-readiness/readiness.test.ts @@ -38,9 +38,7 @@ const processed = ( const goodInput = (over: Partial = {}): DiagnoseInput => ({ attendeeIds: new Set([1]), attendees: [{ id: 1, pii_blob: enc("hyb:1:x") }], - orderedProcessedSessionIds: ["sess-1"], ownerKeyAvailable: true, - pageSize: 500, processed: [processed({ provider_refunded_at: "2026-01-02T00:00:00.000Z" })], stages: [stage({})], sumup: [ @@ -50,7 +48,7 @@ const goodInput = (over: Partial = {}): DiagnoseInput => ({ sumup_id: "su-1", }, ], - undecryptableMergeReferences: new Set(), + undecryptablePaymentReferences: new Set(), undecryptablePii: new Set(), ...over, }); @@ -66,7 +64,6 @@ const mergeRefNoOwnerInput = ( goodInput({ attendeeIds: new Set([1]), attendees: [{ id: 1, pii_blob: "" }], - orderedProcessedSessionIds: [ref], ownerKeyAvailable: false, processed: [ processed({ @@ -232,7 +229,6 @@ describe("diagnoseReadiness", () => { { id: 1, pii_blob: enc("hyb:1:x") }, { id: 2, pii_blob: enc("hyb:1:y") }, ], - orderedProcessedSessionIds: [ref, "sess-1"], processed: [ processed({ attendee_id: 1, payment_session_id: ref }), processed({ payment_session_id: "sess-1" }), @@ -249,7 +245,6 @@ describe("diagnoseReadiness", () => { const report = diagnoseReadiness( goodInput({ attendeeIds: new Set([1, 2]), - orderedProcessedSessionIds: [ref], processed: [processed({ attendee_id: 9, payment_session_id: ref })], }), ); @@ -265,7 +260,7 @@ describe("diagnoseReadiness", () => { expect(report.kind).toBe("blocked"); expect(report.contradictions).toContainEqual({ detail: - "1 encrypted attendee PII blob(s) and no merge-reference charges cannot be verified without the owner key", + "1 encrypted attendee PII blob(s) and no payment references cannot be verified without the owner key", kind: "owner_key_unavailable", }); }); @@ -282,7 +277,7 @@ describe("diagnoseReadiness", () => { expect( report.contradictions.find((c) => c.kind === "owner_key_unavailable") ?.detail, - ).toContain("encrypted merge-reference charge(s)"); + ).toContain("encrypted payment reference(s)"); }); test("does not block on a merge reference when the charge is empty and no owner key is supplied", () => { @@ -324,7 +319,6 @@ describe("diagnoseReadiness", () => { const ref = `${LEGACY_MERGE_SESSION_PREFIX}1`; const report = diagnoseReadiness( goodInput({ - orderedProcessedSessionIds: [ref, "sess-1"], processed: [ processed({ attendee_id: 1, @@ -333,7 +327,7 @@ describe("diagnoseReadiness", () => { }), processed({ payment_session_id: "sess-1" }), ], - undecryptableMergeReferences: new Set([ref]), + undecryptablePaymentReferences: new Set([ref]), }), ); expect(report.kind).toBe("blocked"); @@ -378,20 +372,69 @@ describe("diagnoseReadiness", () => { }); }); - test("reports a provider payment split across a keyset page", () => { + test("blocks on an empty required timestamp (processed_at is NOT NULL)", () => { const report = diagnoseReadiness( goodInput({ - orderedProcessedSessionIds: ["sess-1", "sess-1"], - pageSize: 1, + processed: [processed({ processed_at: "" })], }), ); expect(report.kind).toBe("blocked"); expect(report.contradictions).toContainEqual({ - detail: "sess-1", - kind: "payment_split_across_page", + detail: "processed_payments.processed_at = ", + kind: "unconvertible_timestamp", }); }); + test("does not block on an empty optional timestamp (provider_refunded_at)", () => { + const report = diagnoseReadiness( + goodInput({ + processed: [processed({ provider_refunded_at: "" })], + }), + ); + expect( + report.contradictions.some((c) => c.kind === "unconvertible_timestamp"), + ).toBe(false); + }); + + test("blocks on an empty required checkout_stages.created_at", () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}2`; + const report = diagnoseReadiness( + goodInput({ + attendeeIds: new Set([1]), + attendees: [{ id: 1, pii_blob: "" }], + ownerKeyAvailable: false, + processed: [processed({ attendee_id: 1, payment_session_id: ref })], + stages: [stage({ created_at: "" })], + }), + ); + expect( + report.contradictions.some( + (c) => + c.kind === "unconvertible_timestamp" && + c.detail.includes("checkout_stages.created_at"), + ), + ).toBe(true); + }); + + test("blocks on an empty required sumup_checkouts.created_at", () => { + const report = diagnoseReadiness( + goodInput({ + sumup: [{ created_at: "", reference_index: "idx", sumup_id: "su" }], + }), + ); + expect( + report.contradictions.some( + (c) => + c.kind === "unconvertible_timestamp" && + c.detail.includes("sumup_checkouts.created_at"), + ), + ).toBe(true); + }); + + test("rejects an epoch-millis value outside Date's representable range", () => { + expect(convertLegacyTimestamp("99999999999999999999")).toBeNull(); + }); + test("counts timestamp conversions actually performed", () => { const report = diagnoseReadiness(goodInput()); expect(report.counts.timestampConversions).toBeGreaterThan(0); @@ -437,7 +480,7 @@ describe("formatReadinessReport", () => { " not supplied — 1 attendee PII blob(s) cannot be verified", "", "Contradictions", - " - owner key not supplied: 1 encrypted attendee PII blob(s) and no merge-reference charges cannot be verified without the owner key", + " - owner key not supplied: 1 encrypted attendee PII blob(s) and no payment references cannot be verified without the owner key", ]); }); @@ -452,8 +495,12 @@ describe("formatReadinessReport", () => { expect(report.contradictions).toEqual([]); }); - test("is ready when each payment appears once even at a page size of one", () => { - const report = diagnoseReadiness(goodInput({ pageSize: 1 })); + test("is ready when a single consistent payment is read with a small page size", () => { + // The page-split check is gone: payment_session_id is a primary key, so a + // single payment can never appear more than once per table, and a keyset + // page over one table can't split it. (Split detection is a PR 14 copy- + // cursor concern, not this read-only verifier.) + const report = diagnoseReadiness(goodInput()); expect(report.kind).toBe("ready"); }); @@ -462,9 +509,7 @@ describe("formatReadinessReport", () => { const report = diagnoseReadiness({ attendeeIds: new Set([1, 7]), attendees: [{ id: 7, pii_blob: enc("hyb:1:p") }], - orderedProcessedSessionIds: ["x", "x", "x", ref, "sess-1"], ownerKeyAvailable: true, - pageSize: 1, processed: [ processed({ attendee_id: 88, @@ -472,7 +517,11 @@ describe("formatReadinessReport", () => { payment_session_id: ref, processed_at: "not-a-time", }), - processed({ payment_session_id: "sess-1" }), + processed({ + attendee_id: 1, + payment_reference: enc("hyb:1:regular"), + payment_session_id: "sess-1", + }), ], stages: [stage({ attendee_id: 66, payment_session_id: "orphan" })], sumup: [ @@ -482,7 +531,7 @@ describe("formatReadinessReport", () => { sumup_id: "", }, ], - undecryptableMergeReferences: new Set([ref]), + undecryptablePaymentReferences: new Set([ref, "sess-1"]), undecryptablePii: new Set([7]), }); const out = formatReadinessReport(report).join("\n"); @@ -492,12 +541,14 @@ describe("formatReadinessReport", () => { ); expect(out).toContain(" - checkout stage without a live attendee: 66"); expect(out).toContain(" - processed payment without a live attendee: 88"); - expect(out).toContain(" - provider payment split across a page: x"); expect(out).toContain(" - sumup checkout without a recorded id: idx"); expect(out).toContain(" - attendee PII that did not decrypt: attendee 7"); expect(out).toContain( " - merge-reference charge that did not decrypt: legacy-merge:99", ); + expect(out).toContain( + " - captured charge reference that did not decrypt: sess-1", + ); expect(out).toContain(" - timestamp that cannot be converted:"); }); From e12744b9f71f3945c3f97f720c5cf08eeb74fc30 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 9 Aug 2026 15:59:52 +0000 Subject: [PATCH 5/7] Split readiness formatter + tests into focused modules (Codex round 3) Addresses two Codex round-3 P2 findings on file size: - Move formatReadinessReport + CONTRADICTION_PHRASES out of readiness.ts into src/shared/migration-readiness/format.ts (readiness.ts: 486 -> 421 lines; the remaining content is the pure diagnosis rules). - Move the formatReadinessReport test suite into test/shared/migration-readiness/format.test.ts, with shared fixtures (enc/stage/processed/goodInput/mergeRefNoOwnerInput) extracted into fixtures.ts so neither test file duplicates them (readiness.test.ts: 567 -> 425; format.test.ts: 118). readiness.ts mutation stays 100% (90/90); format.ts mutation 100% (37/37). The two 'reject null keys before advancing the cursor' comments need no code change (explained in review replies): every paged table uses ORDER BY pk ASC, and SQLite sorts NULLs first, so the cursor (last row of each page) is never NULL and pk > NULL truncation cannot occur; NULL-pk rows are read, not skipped. --- scripts/migration-verify-lib.ts | 2 +- src/shared/migration-readiness/format.ts | 79 +++++++ src/shared/migration-readiness/readiness.ts | 65 ------ test/shared/migration-readiness/fixtures.ts | 82 +++++++ .../shared/migration-readiness/format.test.ts | 118 ++++++++++ .../migration-readiness/readiness.test.ts | 208 +++--------------- 6 files changed, 313 insertions(+), 241 deletions(-) create mode 100644 src/shared/migration-readiness/format.ts create mode 100644 test/shared/migration-readiness/fixtures.ts create mode 100644 test/shared/migration-readiness/format.test.ts diff --git a/scripts/migration-verify-lib.ts b/scripts/migration-verify-lib.ts index 9ecf850895..e0d21ecbb9 100644 --- a/scripts/migration-verify-lib.ts +++ b/scripts/migration-verify-lib.ts @@ -16,11 +16,11 @@ import { parseArgs } from "@std/cli/parse-args"; import type { ScriptIo } from "#scripts/script-runner.ts"; +import { formatReadinessReport } from "#shared/migration-readiness/format.ts"; import { type AttendeePiiSource, type CheckoutStageRow, diagnoseReadiness, - formatReadinessReport, type ProcessedPaymentRow, type SumupCheckoutRow, } from "#shared/migration-readiness/readiness.ts"; diff --git a/src/shared/migration-readiness/format.ts b/src/shared/migration-readiness/format.ts new file mode 100644 index 0000000000..da8708ff13 --- /dev/null +++ b/src/shared/migration-readiness/format.ts @@ -0,0 +1,79 @@ +/** + * Plain-language rendering of a migration-readiness verdict. + * + * Split out of `readiness.ts` so the pure diagnosis rules and the operator + * report rendering stay under the ~400-line file target each. The report + * carries only non-secret identifying context (payment session ids, attendee + * ids, counts) — never PII plaintext. + */ + +import type { + ContradictionKind, + ReadinessReport, +} from "#shared/migration-readiness/readiness.ts"; + +const CONTRADICTION_PHRASES: Record = { + checkout_stage_without_attendee: "checkout stage without a live attendee", + checkout_stage_without_processed_payment: + "checkout stage without a processed payment", + owner_key_unavailable: "owner key not supplied", + processed_payment_without_attendee: + "processed payment without a live attendee", + sumup_checkout_without_id: "sumup checkout without a recorded id", + unconvertible_timestamp: "timestamp that cannot be converted", + undecryptable_attendee_pii: "attendee PII that did not decrypt", + undecryptable_merge_reference: "merge-reference charge that did not decrypt", + undecryptable_payment_reference: + "captured charge reference that did not decrypt", +}; + +/** Render a readiness verdict as plain operator lines. The owner-key line says + * how many PII blobs were verified (or that the key was not supplied), and the + * contradiction lines use plain phrases over non-secret detail only. */ +export const formatReadinessReport = (report: ReadinessReport): string[] => { + const lines: string[] = []; + const heading = + report.kind === "ready" + ? "Payment migration readiness: ready" + : `Payment migration readiness: BLOCKED — ${report.contradictions.length} contradiction(s)`; + lines.push(heading, ""); + lines.push( + "Source counts", + ` processed_payments rows: ${report.counts.processedPayments}`, + ` checkout_stages rows: ${report.counts.checkoutStages}`, + ` sumup_checkouts rows: ${report.counts.sumupCheckouts}`, + ` attendee PII blobs: ${report.counts.attendeePiiBlobs}`, + ` merge references: ${report.counts.mergeReferences}`, + ` payment groups: ${report.counts.paymentGroups}`, + ` timestamps converted: ${report.counts.timestampConversions}`, + "", + ); + const ownerKeyMissing = report.contradictions.some( + (c) => c.kind === "owner_key_unavailable", + ); + if (ownerKeyMissing) { + lines.push( + "Owner key", + ` not supplied — ${report.counts.attendeePiiBlobs} attendee PII blob(s) cannot be verified`, + "", + ); + } else if (report.counts.attendeePiiBlobs > 0) { + const verified = + report.counts.attendeePiiBlobs - + report.contradictions.filter( + (c) => c.kind === "undecryptable_attendee_pii", + ).length; + lines.push( + "Owner key", + ` verified ${verified} of ${report.counts.attendeePiiBlobs} attendee PII blob(s)`, + "", + ); + } + if (report.contradictions.length > 0) { + lines.push("Contradictions"); + for (const { detail, kind } of report.contradictions) { + lines.push(` - ${CONTRADICTION_PHRASES[kind]}: ${detail}`); + } + } + return lines; +}; diff --git a/src/shared/migration-readiness/readiness.ts b/src/shared/migration-readiness/readiness.ts index 259ffdf00b..2a72a19d8e 100644 --- a/src/shared/migration-readiness/readiness.ts +++ b/src/shared/migration-readiness/readiness.ts @@ -419,68 +419,3 @@ export const diagnoseReadiness = (input: DiagnoseInput): ReadinessReport => { kind: contradictions.length === 0 ? "ready" : "blocked", }; }; -const CONTRADICTION_PHRASES: Record = { - checkout_stage_without_attendee: "checkout stage without a live attendee", - checkout_stage_without_processed_payment: - "checkout stage without a processed payment", - owner_key_unavailable: "owner key not supplied", - processed_payment_without_attendee: - "processed payment without a live attendee", - sumup_checkout_without_id: "sumup checkout without a recorded id", - unconvertible_timestamp: "timestamp that cannot be converted", - undecryptable_attendee_pii: "attendee PII that did not decrypt", - undecryptable_merge_reference: "merge-reference charge that did not decrypt", - undecryptable_payment_reference: - "captured charge reference that did not decrypt", -}; - -/** Render a readiness verdict as plain operator lines. The owner-key line says - * how many PII blobs were verified (or that the key was not supplied), and the - * contradiction lines use plain phrases over non-secret detail only. */ -export const formatReadinessReport = (report: ReadinessReport): string[] => { - const lines: string[] = []; - const heading = - report.kind === "ready" - ? "Payment migration readiness: ready" - : `Payment migration readiness: BLOCKED — ${report.contradictions.length} contradiction(s)`; - lines.push(heading, ""); - lines.push( - "Source counts", - ` processed_payments rows: ${report.counts.processedPayments}`, - ` checkout_stages rows: ${report.counts.checkoutStages}`, - ` sumup_checkouts rows: ${report.counts.sumupCheckouts}`, - ` attendee PII blobs: ${report.counts.attendeePiiBlobs}`, - ` merge references: ${report.counts.mergeReferences}`, - ` payment groups: ${report.counts.paymentGroups}`, - ` timestamps converted: ${report.counts.timestampConversions}`, - "", - ); - const ownerKeyMissing = report.contradictions.some( - (c) => c.kind === "owner_key_unavailable", - ); - if (ownerKeyMissing) { - lines.push( - "Owner key", - ` not supplied — ${report.counts.attendeePiiBlobs} attendee PII blob(s) cannot be verified`, - "", - ); - } else if (report.counts.attendeePiiBlobs > 0) { - const verified = - report.counts.attendeePiiBlobs - - report.contradictions.filter( - (c) => c.kind === "undecryptable_attendee_pii", - ).length; - lines.push( - "Owner key", - ` verified ${verified} of ${report.counts.attendeePiiBlobs} attendee PII blob(s)`, - "", - ); - } - if (report.contradictions.length > 0) { - lines.push("Contradictions"); - for (const { detail, kind } of report.contradictions) { - lines.push(` - ${CONTRADICTION_PHRASES[kind]}: ${detail}`); - } - } - return lines; -}; diff --git a/test/shared/migration-readiness/fixtures.ts b/test/shared/migration-readiness/fixtures.ts new file mode 100644 index 0000000000..b48f9ccc64 --- /dev/null +++ b/test/shared/migration-readiness/fixtures.ts @@ -0,0 +1,82 @@ +/** + * Shared fixtures for the migration-readiness tests: the type-cast helper for + * owner-key ciphertext, the row builders, and the canonical "good" diagnose + * input. Split out so the diagnosis suite and the formatter suite both reuse + * them without duplication. + */ + +import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; +import type { + CheckoutStageRow, + DiagnoseInput, + ProcessedPaymentRow, +} from "#shared/migration-readiness/readiness.ts"; + +/** Re-exported so test files can brand string literals as owner-key ciphertext + * without each importing the sealed type. */ +export type { OwnerKeyEncrypted }; + +export const enc = (s: string): OwnerKeyEncrypted => s as OwnerKeyEncrypted; + +export const stage = (over: Partial): CheckoutStageRow => ({ + attendee_id: 1, + created_at: "2026-01-01T00:00:00.000Z", + payment_session_id: "sess-1", + provider: "stripe", + state: "completed", + ...over, +}); + +export const processed = ( + over: Partial, +): ProcessedPaymentRow => ({ + attendee_id: 1, + failure_data: "", + payment_reference: "", + payment_session_id: "sess-1", + processed_at: "2026-01-01T00:00:00.000Z", + provider_refunded_at: "", + ...over, +}); + +export const goodInput = ( + over: Partial = {}, +): DiagnoseInput => ({ + attendeeIds: new Set([1]), + attendees: [{ id: 1, pii_blob: enc("hyb:1:x") }], + ownerKeyAvailable: true, + processed: [processed({ provider_refunded_at: "2026-01-02T00:00:00.000Z" })], + stages: [stage({})], + sumup: [ + { + created_at: "2026-01-01T00:00:00.000Z", + reference_index: "idx-1", + sumup_id: "su-1", + }, + ], + undecryptablePaymentReferences: new Set(), + undecryptablePii: new Set(), + ...over, +}); + +/** A no-owner-key input with one `legacy-merge:*` row on attendee 1 (no PII), + * varying only in whether the charge reference is encrypted or empty. Shared by + * the "blocks on encrypted merge charge" and "does not block on empty charge" + * cases so they differ in exactly the one fact under test. */ +export const mergeRefNoOwnerInput = ( + ref: string, + charge: OwnerKeyEncrypted | "", +): DiagnoseInput => + goodInput({ + attendeeIds: new Set([1]), + attendees: [{ id: 1, pii_blob: "" }], + ownerKeyAvailable: false, + processed: [ + processed({ + attendee_id: 1, + payment_reference: charge, + payment_session_id: ref, + }), + ], + stages: [], + }); diff --git a/test/shared/migration-readiness/format.test.ts b/test/shared/migration-readiness/format.test.ts new file mode 100644 index 0000000000..50ac7b3b06 --- /dev/null +++ b/test/shared/migration-readiness/format.test.ts @@ -0,0 +1,118 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { formatReadinessReport } from "#shared/migration-readiness/format.ts"; +import { + diagnoseReadiness, + LEGACY_MERGE_SESSION_PREFIX, +} from "#shared/migration-readiness/readiness.ts"; +import { + enc, + goodInput, + processed, + stage, +} from "#test/shared/migration-readiness/fixtures.ts"; + +describe("formatReadinessReport", () => { + test("states ready with exact source counts and the owner-key verdict", () => { + const report = diagnoseReadiness(goodInput()); + expect(formatReadinessReport(report)).toEqual([ + "Payment migration readiness: ready", + "", + "Source counts", + " processed_payments rows: 1", + " checkout_stages rows: 1", + " sumup_checkouts rows: 1", + " attendee PII blobs: 1", + " merge references: 0", + " payment groups: 1", + " timestamps converted: 4", + "", + "Owner key", + " verified 1 of 1 attendee PII blob(s)", + "", + ]); + }); + + test("states blocked and lists the owner-key contradiction in plain language", () => { + const report = diagnoseReadiness(goodInput({ ownerKeyAvailable: false })); + expect(formatReadinessReport(report)).toEqual([ + "Payment migration readiness: BLOCKED — 1 contradiction(s)", + "", + "Source counts", + " processed_payments rows: 1", + " checkout_stages rows: 1", + " sumup_checkouts rows: 1", + " attendee PII blobs: 1", + " merge references: 0", + " payment groups: 1", + " timestamps converted: 4", + "", + "Owner key", + " not supplied — 1 attendee PII blob(s) cannot be verified", + "", + "Contradictions", + " - owner key not supplied: 1 encrypted attendee PII blob(s) and no payment references cannot be verified without the owner key", + ]); + }); + + test("lists every contradiction phrase when each kind fires", () => { + const ref = `${LEGACY_MERGE_SESSION_PREFIX}99`; + const report = diagnoseReadiness({ + attendeeIds: new Set([1, 7]), + attendees: [{ id: 7, pii_blob: enc("hyb:1:p") }], + ownerKeyAvailable: true, + processed: [ + processed({ + attendee_id: 88, + payment_reference: enc("hyb:1:charge"), + payment_session_id: ref, + processed_at: "not-a-time", + }), + processed({ + attendee_id: 1, + payment_reference: enc("hyb:1:regular"), + payment_session_id: "sess-1", + }), + ], + stages: [stage({ attendee_id: 66, payment_session_id: "orphan" })], + sumup: [ + { + created_at: "2026-01-01T00:00:00.000Z", + reference_index: "idx", + sumup_id: "", + }, + ], + undecryptablePaymentReferences: new Set([ref, "sess-1"]), + undecryptablePii: new Set([7]), + }); + const out = formatReadinessReport(report).join("\n"); + expect(out).toContain("Contradictions"); + expect(out).toContain( + " - checkout stage without a processed payment: orphan", + ); + expect(out).toContain(" - checkout stage without a live attendee: 66"); + expect(out).toContain(" - processed payment without a live attendee: 88"); + expect(out).toContain(" - sumup checkout without a recorded id: idx"); + expect(out).toContain(" - attendee PII that did not decrypt: attendee 7"); + expect(out).toContain( + " - merge-reference charge that did not decrypt: legacy-merge:99", + ); + expect(out).toContain( + " - captured charge reference that did not decrypt: sess-1", + ); + expect(out).toContain(" - timestamp that cannot be converted:"); + }); + + test("does not leak attendee PII plaintext into the detail", () => { + const report = diagnoseReadiness( + goodInput({ + attendeeIds: new Set([1, 7]), + attendees: [{ id: 7, pii_blob: enc("hyb:1:super-secret") }], + undecryptablePii: new Set([7]), + }), + ); + const lines = formatReadinessReport(report).join("\n"); + expect(lines).toContain("attendee 7"); + expect(lines).not.toContain("super-secret"); + }); +}); diff --git a/test/shared/migration-readiness/readiness.test.ts b/test/shared/migration-readiness/readiness.test.ts index 68abd735ff..0b84dc5293 100644 --- a/test/shared/migration-readiness/readiness.test.ts +++ b/test/shared/migration-readiness/readiness.test.ts @@ -1,79 +1,18 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import type { OwnerKeyEncrypted } from "#shared/crypto/sealed.ts"; import { buildPaymentGroups, - type CheckoutStageRow, convertLegacyTimestamp, - type DiagnoseInput, diagnoseReadiness, - formatReadinessReport, LEGACY_MERGE_SESSION_PREFIX, - type ProcessedPaymentRow, } from "#shared/migration-readiness/readiness.ts"; - -const enc = (s: string): OwnerKeyEncrypted => s as OwnerKeyEncrypted; - -const stage = (over: Partial): CheckoutStageRow => ({ - attendee_id: 1, - created_at: "2026-01-01T00:00:00.000Z", - payment_session_id: "sess-1", - provider: "stripe", - state: "completed", - ...over, -}); - -const processed = ( - over: Partial, -): ProcessedPaymentRow => ({ - attendee_id: 1, - failure_data: "", - payment_reference: "", - payment_session_id: "sess-1", - processed_at: "2026-01-01T00:00:00.000Z", - provider_refunded_at: "", - ...over, -}); - -const goodInput = (over: Partial = {}): DiagnoseInput => ({ - attendeeIds: new Set([1]), - attendees: [{ id: 1, pii_blob: enc("hyb:1:x") }], - ownerKeyAvailable: true, - processed: [processed({ provider_refunded_at: "2026-01-02T00:00:00.000Z" })], - stages: [stage({})], - sumup: [ - { - created_at: "2026-01-01T00:00:00.000Z", - reference_index: "idx-1", - sumup_id: "su-1", - }, - ], - undecryptablePaymentReferences: new Set(), - undecryptablePii: new Set(), - ...over, -}); - -/** A no-owner-key input with one `legacy-merge:*` row on attendee 1 (no PII), - * varying only in whether the charge reference is encrypted or empty. Shared by - * the "blocks on encrypted merge charge" and "does not block on empty charge" - * cases so they differ in exactly the one fact under test. */ -const mergeRefNoOwnerInput = ( - ref: string, - charge: OwnerKeyEncrypted | "", -): DiagnoseInput => - goodInput({ - attendeeIds: new Set([1]), - attendees: [{ id: 1, pii_blob: "" }], - ownerKeyAvailable: false, - processed: [ - processed({ - attendee_id: 1, - payment_reference: charge, - payment_session_id: ref, - }), - ], - stages: [], - }); +import { + enc, + goodInput, + mergeRefNoOwnerInput, + processed, + stage, +} from "#test/shared/migration-readiness/fixtures.ts"; describe("convertLegacyTimestamp", () => { test("canonicalises an ISO instant with an offset to …sssZ", () => { @@ -337,6 +276,29 @@ describe("diagnoseReadiness", () => { }); }); + test("reports a regular captured charge reference that fails to decrypt", () => { + // A non-merge payment_session_id whose payment_reference failed to decrypt + // is classified as undecryptable_payment_reference (distinct from a merge- + // reference charge). + const report = diagnoseReadiness( + goodInput({ + processed: [ + processed({ + attendee_id: 1, + payment_reference: enc("hyb:1:charge"), + payment_session_id: "sess-1", + }), + ], + undecryptablePaymentReferences: new Set(["sess-1"]), + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "sess-1", + kind: "undecryptable_payment_reference", + }); + }); + test("reports an unconvertible timestamp", () => { const report = diagnoseReadiness( goodInput({ @@ -437,53 +399,10 @@ describe("diagnoseReadiness", () => { test("counts timestamp conversions actually performed", () => { const report = diagnoseReadiness(goodInput()); - expect(report.counts.timestampConversions).toBeGreaterThan(0); - }); -}); - -describe("formatReadinessReport", () => { - test("states ready with exact source counts and the owner-key verdict", () => { - const report = diagnoseReadiness(goodInput()); - expect(formatReadinessReport(report)).toEqual([ - "Payment migration readiness: ready", - "", - "Source counts", - " processed_payments rows: 1", - " checkout_stages rows: 1", - " sumup_checkouts rows: 1", - " attendee PII blobs: 1", - " merge references: 0", - " payment groups: 1", - " timestamps converted: 4", - "", - "Owner key", - " verified 1 of 1 attendee PII blob(s)", - "", - ]); - }); - - test("states blocked and lists the owner-key contradiction in plain language", () => { - const report = diagnoseReadiness(goodInput({ ownerKeyAvailable: false })); - expect(formatReadinessReport(report)).toEqual([ - "Payment migration readiness: BLOCKED — 1 contradiction(s)", - "", - "Source counts", - " processed_payments rows: 1", - " checkout_stages rows: 1", - " sumup_checkouts rows: 1", - " attendee PII blobs: 1", - " merge references: 0", - " payment groups: 1", - " timestamps converted: 4", - "", - "Owner key", - " not supplied — 1 attendee PII blob(s) cannot be verified", - "", - "Contradictions", - " - owner key not supplied: 1 encrypted attendee PII blob(s) and no payment references cannot be verified without the owner key", - ]); + // processed_at + provider_refunded_at + checkout_stages.created_at + + // sumup_checkouts.created_at — the goodInput rows all carry real instants. + expect(report.counts.timestampConversions).toBe(4); }); - test("is ready with an empty-blob attendee and no owner key (nothing encrypted to skip)", () => { const report = diagnoseReadiness( goodInput({ @@ -503,65 +422,4 @@ describe("formatReadinessReport", () => { const report = diagnoseReadiness(goodInput()); expect(report.kind).toBe("ready"); }); - - test("lists every contradiction phrase when each kind fires", () => { - const ref = `${LEGACY_MERGE_SESSION_PREFIX}99`; - const report = diagnoseReadiness({ - attendeeIds: new Set([1, 7]), - attendees: [{ id: 7, pii_blob: enc("hyb:1:p") }], - ownerKeyAvailable: true, - processed: [ - processed({ - attendee_id: 88, - payment_reference: enc("hyb:1:charge"), - payment_session_id: ref, - processed_at: "not-a-time", - }), - processed({ - attendee_id: 1, - payment_reference: enc("hyb:1:regular"), - payment_session_id: "sess-1", - }), - ], - stages: [stage({ attendee_id: 66, payment_session_id: "orphan" })], - sumup: [ - { - created_at: "2026-01-01T00:00:00.000Z", - reference_index: "idx", - sumup_id: "", - }, - ], - undecryptablePaymentReferences: new Set([ref, "sess-1"]), - undecryptablePii: new Set([7]), - }); - const out = formatReadinessReport(report).join("\n"); - expect(out).toContain("Contradictions"); - expect(out).toContain( - " - checkout stage without a processed payment: orphan", - ); - expect(out).toContain(" - checkout stage without a live attendee: 66"); - expect(out).toContain(" - processed payment without a live attendee: 88"); - expect(out).toContain(" - sumup checkout without a recorded id: idx"); - expect(out).toContain(" - attendee PII that did not decrypt: attendee 7"); - expect(out).toContain( - " - merge-reference charge that did not decrypt: legacy-merge:99", - ); - expect(out).toContain( - " - captured charge reference that did not decrypt: sess-1", - ); - expect(out).toContain(" - timestamp that cannot be converted:"); - }); - - test("does not leak attendee PII plaintext into the detail", () => { - const report = diagnoseReadiness( - goodInput({ - attendeeIds: new Set([1, 7]), - attendees: [{ id: 7, pii_blob: enc("hyb:1:super-secret") }], - undecryptablePii: new Set([7]), - }), - ); - const lines = formatReadinessReport(report).join("\n"); - expect(lines).toContain("attendee 7"); - expect(lines).not.toContain("super-secret"); - }); }); From 32579fe70e56acc3cae6b19cc08d1118d7344dab Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 9 Aug 2026 16:20:10 +0000 Subject: [PATCH 6/7] Address Codex round 4: attendee mismatch, hybrid-only payment refs, empty-decrypted charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Codex round-4 P2 findings, all verified and fixed: 1. (readiness) A session whose checkout_stages row and processed_payments row name different live attendees is now a contradiction (checkout_stage_attendee_mismatch) — previously passed because the stage and processed attendee checks were independent. 2. (readiness) hasEncryptedPaymentReference now keys on the hybrid ciphertext prefix, so a database with only legacy plaintext payment_reference rows no longer blocks when --owner is omitted (plaintext charges do not need the owner key). 3. (deps) paymentReferenceDecrypts rejects a hybrid payment_reference that decrypts to an empty string (an encrypted-but-empty charge is corrupt). Integration tests: 'blocks on a hybrid payment_reference that decrypts to an empty string' and unit: mismatch + plaintext-ref-doesn't-block. readiness.ts mutation 100% (101/101). --- scripts/migration-verify-deps.ts | 10 ++-- src/shared/migration-readiness/format.ts | 2 + src/shared/migration-readiness/readiness.ts | 47 +++++++++++++++---- test/integration/migration-verify.test.ts | 20 ++++++++ .../shared/migration-readiness/format.test.ts | 8 +++- .../migration-readiness/readiness.test.ts | 36 ++++++++++++++ 6 files changed, 110 insertions(+), 13 deletions(-) diff --git a/scripts/migration-verify-deps.ts b/scripts/migration-verify-deps.ts index 7a821a0d41..e41085f656 100644 --- a/scripts/migration-verify-deps.ts +++ b/scripts/migration-verify-deps.ts @@ -128,15 +128,19 @@ export const createMigrationVerifyReader = ( * empty value is nothing to verify. A non-hybrid value is a legacy plaintext * payment_reference (development builds wrote the column in the clear — see * `payment-references.ts`), so it is treated as decryptable. A hybrid - * ciphertext that throws on decrypt is not. Returns no plaintext. */ + * ciphertext that throws on decrypt, or decrypts to an empty string (an + * encrypted-but-empty charge is corrupt), is not. Returns no plaintext. */ const paymentReferenceDecrypts = async ( value: OwnerKeyEncrypted | "", key: CryptoKey, ): Promise => { if (value === "" || !value.startsWith(HYBRID_PREFIX)) return true; try { - await decryptWithOwnerKey(value as OwnerKeyEncrypted, key); - return true; + const plaintext = await decryptWithOwnerKey( + value as OwnerKeyEncrypted, + key, + ); + return plaintext !== ""; } catch { return false; } diff --git a/src/shared/migration-readiness/format.ts b/src/shared/migration-readiness/format.ts index da8708ff13..e5a01fe68a 100644 --- a/src/shared/migration-readiness/format.ts +++ b/src/shared/migration-readiness/format.ts @@ -13,6 +13,8 @@ import type { } from "#shared/migration-readiness/readiness.ts"; const CONTRADICTION_PHRASES: Record = { + checkout_stage_attendee_mismatch: + "checkout stage and processed payment disagree on attendee", checkout_stage_without_attendee: "checkout stage without a live attendee", checkout_stage_without_processed_payment: "checkout stage without a processed payment", diff --git a/src/shared/migration-readiness/readiness.ts b/src/shared/migration-readiness/readiness.ts index 2a72a19d8e..489fede7bb 100644 --- a/src/shared/migration-readiness/readiness.ts +++ b/src/shared/migration-readiness/readiness.ts @@ -13,6 +13,7 @@ */ import { Temporal } from "temporal-polyfill"; +import { HYBRID_PREFIX } from "#shared/crypto/keys.ts"; import type { EnvKeyEncrypted, OwnerKeyEncrypted, @@ -79,6 +80,7 @@ export type PaymentGroup = { export type ContradictionKind = | "checkout_stage_without_processed_payment" + | "checkout_stage_attendee_mismatch" | "checkout_stage_without_attendee" | "processed_payment_without_attendee" | "undecryptable_attendee_pii" @@ -145,14 +147,15 @@ export const convertLegacyTimestamp = (value: string): string | null => { const isMergeReference = (sessionId: string): boolean => sessionId.startsWith(LEGACY_MERGE_SESSION_PREFIX); - /** A `processed_payments` row that carries an owner-key-encrypted * `payment_reference` — a captured charge (regular or merge-reference) only - * the owner key can verify. Used to block the migration when the key is - * unavailable, instead of silently skipping the charge. */ + * the owner key can verify. Only hybrid ciphertext needs the key: a legacy + * plaintext `payment_reference` (development builds wrote the column in the + * clear) is not encrypted, so it does not block when the key is absent. */ const hasEncryptedPaymentReference = ( processed: readonly ProcessedPaymentRow[], -): boolean => processed.some((row) => row.payment_reference !== ""); +): boolean => + processed.some((row) => row.payment_reference.startsWith(HYBRID_PREFIX)); /** Convert every legacy timestamp column to a canonical instant, returning one * contradiction per value that is neither a real instant nor a representable @@ -286,12 +289,36 @@ export type DiagnoseInput = { const isTerminalFailure = (row: ProcessedPaymentRow): boolean => row.attendee_id === null && row.failure_data !== ""; +/** When a session has both a stage and a processed payment whose attendees are + * both live, they must agree; a disagreement is corruption. Returns null when + * either side is absent, either attendee is missing/deleted, or they match. */ +const stageProcessedMismatch = ( + group: PaymentGroup, + attendeeIds: ReadonlySet, +): Contradiction | null => { + const { stage, processed } = group; + if (!stage || !processed) return null; + const processedAttendee = processed.attendee_id; + if ( + processedAttendee === null || + !attendeeIds.has(processedAttendee) || + stage.attendee_id === processedAttendee + ) { + return null; + } + return { + detail: `${stage.attendee_id} vs ${processedAttendee}`, + kind: "checkout_stage_attendee_mismatch", + }; +}; + /** Contradictions in one payment group: a checkout stage whose session has no - * processed payment, a stage whose own attendee has been deleted, or a - * non-terminal processed payment pointing at a missing attendee. A - * merge-reference row's `attendee_id` is the merge target (the source is - * deleted by `applyAttendeeMerge` in the same batch), so its existence is - * covered here — there is no separate "source attendee" expectation. */ + * processed payment, a stage whose own attendee has been deleted, a stage and + * processed payment that disagree on attendee, or a non-terminal processed + * payment pointing at a missing attendee. A merge-reference row's + * `attendee_id` is the merge target (the source is deleted by + * `applyAttendeeMerge` in the same batch), so its existence is covered here — + * there is no separate "source attendee" expectation. */ const groupContradictions = ( group: PaymentGroup, attendeeIds: ReadonlySet, @@ -320,6 +347,8 @@ const groupContradictions = ( }); } } + const mismatch = stageProcessedMismatch(group, attendeeIds); + if (mismatch) contradictions.push(mismatch); return contradictions; }; diff --git a/test/integration/migration-verify.test.ts b/test/integration/migration-verify.test.ts index 371e66fe95..836abd8db1 100644 --- a/test/integration/migration-verify.test.ts +++ b/test/integration/migration-verify.test.ts @@ -374,4 +374,24 @@ describe("migration-verify production wiring", () => { expect(result).toBe(1); expect(out).toContain("processed payment without a live attendee"); }); + + test("blocks on a hybrid payment_reference that decrypts to an empty string", async () => { + const attendeeId = await seedAttendee(); + await seedStage("sess-1", attendeeId); + await seedProcessed("sess-1", attendeeId); + // An encrypted-but-empty charge (hybrid ciphertext of "") is corrupt — it + // must fail readiness rather than pass as a verified charge. + const encryptedEmpty = await encryptWithOwnerKey("", settings.publicKey); + await execute( + `UPDATE processed_payments SET payment_reference = ? WHERE payment_session_id = 'sess-1'`, + [encryptedEmpty], + ); + + const { out, result } = await runOwner(); + + expect(result).toBe(1); + expect(out).toContain( + "captured charge reference that did not decrypt: sess-1", + ); + }); }); diff --git a/test/shared/migration-readiness/format.test.ts b/test/shared/migration-readiness/format.test.ts index 50ac7b3b06..d14159620d 100644 --- a/test/shared/migration-readiness/format.test.ts +++ b/test/shared/migration-readiness/format.test.ts @@ -74,7 +74,10 @@ describe("formatReadinessReport", () => { payment_session_id: "sess-1", }), ], - stages: [stage({ attendee_id: 66, payment_session_id: "orphan" })], + stages: [ + stage({ attendee_id: 66, payment_session_id: "orphan" }), + stage({ attendee_id: 7, payment_session_id: "sess-1" }), + ], sumup: [ { created_at: "2026-01-01T00:00:00.000Z", @@ -91,6 +94,9 @@ describe("formatReadinessReport", () => { " - checkout stage without a processed payment: orphan", ); expect(out).toContain(" - checkout stage without a live attendee: 66"); + expect(out).toContain( + " - checkout stage and processed payment disagree on attendee: 7 vs 1", + ); expect(out).toContain(" - processed payment without a live attendee: 88"); expect(out).toContain(" - sumup checkout without a recorded id: idx"); expect(out).toContain(" - attendee PII that did not decrypt: attendee 7"); diff --git a/test/shared/migration-readiness/readiness.test.ts b/test/shared/migration-readiness/readiness.test.ts index 0b84dc5293..0148ff9db6 100644 --- a/test/shared/migration-readiness/readiness.test.ts +++ b/test/shared/migration-readiness/readiness.test.ts @@ -156,6 +156,42 @@ describe("diagnoseReadiness", () => { }); }); + test("blocks when a stage and its processed payment disagree on attendee", () => { + const report = diagnoseReadiness( + goodInput({ + attendeeIds: new Set([1, 2]), + processed: [processed({ attendee_id: 1 })], + stages: [stage({ attendee_id: 2 })], + }), + ); + expect(report.kind).toBe("blocked"); + expect(report.contradictions).toContainEqual({ + detail: "2 vs 1", + kind: "checkout_stage_attendee_mismatch", + }); + }); + + test("does not block on a legacy plaintext (non-hybrid) payment reference without the owner key", () => { + // A plaintext payment_reference is not encrypted, so it does not need the + // owner key to verify — readiness must not block when the key is absent + // and only plaintext charges exist. + const report = diagnoseReadiness( + goodInput({ + attendees: [{ id: 1, pii_blob: "" }], + ownerKeyAvailable: false, + processed: [ + processed({ + attendee_id: 1, + payment_reference: "plaintext-charge" as never, + }), + ], + }), + ); + expect( + report.contradictions.some((c) => c.kind === "owner_key_unavailable"), + ).toBe(false); + }); + test("a legitimate merge reference (source deleted, target live) does not block", () => { // applyAttendeeMerge deletes the source attendee and writes // legacy-merge: with attendee_id = target. The source id is From c41d0818a1096b2798b6ca3e61ed25e371700288 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 9 Aug 2026 16:24:47 +0000 Subject: [PATCH 7/7] Close coverage gaps for migration-verify source (100% on all changed files) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the dead empty-blob skip in verify (readAttendeePii filters pii_blob != '', so empty blobs never reach it) — was an uncovered, impossible branch. - Add CLI tests for --page-size parsing: a numeric value reaches the reader with that page size, and a non-numeric value exits 2 with usage. Extract expectUsageExit to dedup the two usage cases. - Add an integration test for a legacy plaintext (non-hybrid) payment reference with the owner key (covers paymentReferenceDecrypts' non-hybrid branch). All changed source now at 100% line + branch coverage (migration-verify-deps, migration-verify-lib, readiness, format, database-config, owner-kek). --- scripts/migration-verify-deps.ts | 11 ++++---- test/integration/migration-verify.test.ts | 18 +++++++++++++ test/scripts/migration-verify.test.ts | 32 ++++++++++++++++++++--- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/scripts/migration-verify-deps.ts b/scripts/migration-verify-deps.ts index e41085f656..8ec7ce9858 100644 --- a/scripts/migration-verify-deps.ts +++ b/scripts/migration-verify-deps.ts @@ -175,12 +175,13 @@ export const createMigrationVerifyOwnerKey = (): MigrationVerifyOwnerKey => ({ const undecryptablePii = new Set(); const undecryptablePaymentReferences = new Set(); for (const { id, pii_blob } of inputs.attendees) { - if (pii_blob === "") continue; + // readAttendeePii filters pii_blob != '', so every blob here is + // non-empty hybrid ciphertext (or corrupt). Decrypt AND parse: a blob + // that decrypts to malformed JSON or one missing required PII fields + // would fail the real attendee readers, so it must fail readiness too. + // Non-hybrid blobs throw here (PII has no legacy plaintext fallback), + // catching corrupt plaintext PII. try { - // Decrypt AND parse: a blob that decrypts to malformed JSON or one - // missing required PII fields would fail the real attendee readers, so - // it must fail readiness too. Non-hybrid blobs throw here (PII has no - // legacy plaintext fallback), catching corrupt plaintext PII. await decryptPiiBlob(pii_blob as OwnerKeyEncrypted, key, true); } catch { undecryptablePii.add(id); diff --git a/test/integration/migration-verify.test.ts b/test/integration/migration-verify.test.ts index 836abd8db1..2b623462fb 100644 --- a/test/integration/migration-verify.test.ts +++ b/test/integration/migration-verify.test.ts @@ -394,4 +394,22 @@ describe("migration-verify production wiring", () => { "captured charge reference that did not decrypt: sess-1", ); }); + + test("verifies a legacy plaintext (non-hybrid) payment reference with the owner key", async () => { + const attendeeId = await seedAttendee(); + await seedStage("sess-1", attendeeId); + await seedProcessed("sess-1", attendeeId); + // A legacy plaintext payment_reference (development builds wrote the column + // in the clear) is not hybrid ciphertext — it is treated as decryptable and + // must not be flagged undecryptable, and must not block without the owner + // key (only hybrid refs need the key). + await execute( + `UPDATE processed_payments SET payment_reference = 'plaintext-charge' + WHERE payment_session_id = 'sess-1'`, + ); + + const { result } = await runOwner(); + + expect(result).toBe(0); + }); }); diff --git a/test/scripts/migration-verify.test.ts b/test/scripts/migration-verify.test.ts index dd5fb78ca6..9403bb180d 100644 --- a/test/scripts/migration-verify.test.ts +++ b/test/scripts/migration-verify.test.ts @@ -123,6 +123,16 @@ const deps = ( ...over, }); +/** Run a usage-error args case and assert exit 2 + the usage banner. Shared by + * the unknown-flag and non-numeric-page-size cases. */ +const expectUsageExit = async (clip: Clipio): Promise => { + const result = await runMigrationVerifyCli( + deps(clip, { createReader: () => fakeReader() }), + ); + expect(result).toBe(2); + expect(clip.errors.join("\n")).toContain(MIGRATION_VERIFY_USAGE); +}; + describe("runMigrationVerifyCli", () => { test("prints usage and exits 0 for --help", async () => { const result = await runMigrationVerifyCli( @@ -300,11 +310,25 @@ describe("runMigrationVerifyCli", () => { }); test("exits 2 and prints usage for an unknown flag", async () => { - const clip = io(["--bogus"]); + await expectUsageExit(io(["--bogus"])); + }); + + test("honours a numeric --page-size when constructing the reader", async () => { + const clip = io(["--owner", "owner", "--page-size", "40"]); + let seenPageSize = 0; const result = await runMigrationVerifyCli( - deps(clip, { createReader: () => fakeReader() }), + deps(clip, { + createReader: (pageSize) => { + seenPageSize = pageSize; + return fakeReader(); + }, + }), ); - expect(result).toBe(2); - expect(clip.errors.join("\n")).toContain(MIGRATION_VERIFY_USAGE); + expect(result).toBe(0); + expect(seenPageSize).toBe(40); + }); + + test("exits 2 for a non-numeric --page-size", async () => { + await expectUsageExit(io(["--page-size", "abc"])); }); });