diff --git a/migration/1785742000000-BackfillComplianceAndDebugStaffVerifiedNames.js b/migration/1785742000000-BackfillComplianceAndDebugStaffVerifiedNames.js new file mode 100644 index 0000000000..fd4947b85f --- /dev/null +++ b/migration/1785742000000-BackfillComplianceAndDebugStaffVerifiedNames.js @@ -0,0 +1,120 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +// Same character set as `BlankChars` in StaffKycClearanceService — every character +// `String.prototype.trim()` strips. Postgres' bare `TRIM(x)` removes ASCII space only, so a name of a +// single tab or a non-breaking space would pass a `TRIM(x) <> ''` test while the clearance query still +// rejects it. Duplicated rather than imported: migrations are plain JS executed by TypeORM and cannot +// pull in application sources. +const BLANK_CHARS = + '\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007' + + '\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff'; + +// The Compliance account is targeted through its wallet address and resolved to a user-data id in SQL: +// the account's id cannot be looked up beforehand, because every tool that could answer that question +// sits behind the very clearance gate this migration repairs. A wallet address is pseudonymous and +// already public on-chain, so it may appear here (precedent: the GSheet service account in +// 1785584840000-BackfillStaffVerifiedNames). Matching is case-insensitive on purpose — the address was +// transcribed from the app, which renders the EIP-55 checksummed form, and a casing difference against +// the stored value must not decide a boot-fatal assertion. +const COMPLIANCE_ACCOUNT_ADDRESS = '0xBB922dB5F637aAfdc54b1509b231cc07461fb608'; +const DEBUG_ACCOUNT_ID = 395822; + +/** + * PRD-only backfill for two staff accounts of the same staff member that the staff-clearance rule + * (#4395 → #4572) gated out: a Compliance account (resolved via its wallet address) and a Debug + * account (user data 395822). The earlier backfills (#4574, #4590) covered other accounts; these two + * still fail every elevated endpoint with STAFF_KYC_REQUIRED — the entire Compliance tool and + * POST /gs/debug included. Self-service KYC cannot repair them: the KYC flow rejects Compliance-role + * accounts outright (#3577). + * + * Both rows receive the same identity — one person, one reviewed name — read from the single + * deployment variable STAFF_VERIFIED_NAME_395822, keyed by the one account id that is known at review + * time. No plaintext personal name lives in this file; the variable is mandatory on PRD so TypeORM + * cannot record a partial/no-op migration when it is missing. The update is idempotent (only touches + * null-or-blank verifiedName values) and coupled to a durable before/after audit entry. Guarded to + * prd; a no-op elsewhere. + * + * The closing assertion checks the clearance predicate itself, per account, rather than equality with + * the supplied name: should an identity-verified path have written a different (correct) name in the + * meantime, that account is cleared and the migration must not fail the deploy over the spelling. + * That divergence is not silent — it is recorded as its own audit entry, so the deployed state never + * differs from the reviewed one without a trace. + * @class @implements {MigrationInterface} + */ +module.exports = class BackfillComplianceAndDebugStaffVerifiedNames1785742000000 { + name = 'BackfillComplianceAndDebugStaffVerifiedNames1785742000000'; + + async up(queryRunner) { + if (process.env.ENVIRONMENT !== 'prd') return; + + const verifiedName = process.env.STAFF_VERIFIED_NAME_395822?.trim(); + if (!verifiedName) throw new Error('STAFF_VERIFIED_NAME_395822 is required for the PRD staff-name backfill'); + + // `needsBackfill` is the exact negation of the closing assertion below. The two must stay + // complementary: a precondition of `verifiedName IS NULL` against a non-blank postcondition would + // leave a present-but-blank name (a lone tab, a non-breaking space) as a state the migration + // refuses to repair and then refuses to accept — and because `migrationsTransactionMode` defaults + // to 'all', that throw rolls back the whole release's batch and takes the boot down with it. + // + // `noteworthy` is what gets audited: the repair itself, or the deliberate decision to keep a + // divergent name that an identity-verified path wrote in the meantime. A re-run after a successful + // backfill is neither, so it stays a true no-op instead of appending an audit row every time. + // Array.of avoids looking like MSSQL bracket quoting to the repository's migration syntax guard. + await queryRunner.query( + `WITH "targets" AS ( + SELECT "id", + "verifiedName" AS "previousVerifiedName", + BTRIM(COALESCE("verifiedName", ''), $2::varchar) = '' AS "needsBackfill" + FROM "user_data" + WHERE "id" = ${DEBUG_ACCOUNT_ID} + OR "id" IN (SELECT "userDataId" FROM "user" WHERE LOWER("address") = LOWER($3::varchar)) + FOR UPDATE + ), + "noteworthy" AS ( + SELECT "id", "previousVerifiedName", "needsBackfill" + FROM "targets" + WHERE "needsBackfill" OR "previousVerifiedName" IS DISTINCT FROM $1::varchar + ), + "audit" AS ( + INSERT INTO "log" ("created", "updated", "system", "subsystem", "severity", "message") + SELECT now(), now(), 'User', 'StaffVerifiedNameBackfill', 'Info', + json_agg(json_build_object( + 'userDataId', "id", + 'previousVerifiedName', "previousVerifiedName", + 'nextVerifiedName', CASE WHEN "needsBackfill" THEN $1::varchar ELSE "previousVerifiedName" END, + 'action', CASE WHEN "needsBackfill" THEN 'backfilled' ELSE 'keptExistingName' END + ) ORDER BY "id")::text + FROM "noteworthy" + HAVING count(*) > 0 + RETURNING 1 + ) + UPDATE "user_data" ud + SET "verifiedName" = $1::varchar, "updated" = now() + FROM "targets" t + WHERE ud."id" = t."id" AND t."needsBackfill" AND EXISTS (SELECT 1 FROM "audit")`, + Array.of(verifiedName, BLANK_CHARS, COMPLIANCE_ACCOUNT_ADDRESS), + ); + + const rows = await queryRunner.query( + `SELECT + (SELECT count(*)::int FROM "user_data" + WHERE "id" = ${DEBUG_ACCOUNT_ID} AND BTRIM("verifiedName", $1::varchar) <> '') AS "debugCleared", + (SELECT count(*)::int FROM "user_data" ud + WHERE ud."id" IN (SELECT "userDataId" FROM "user" WHERE LOWER("address") = LOWER($2::varchar)) + AND BTRIM(ud."verifiedName", $1::varchar) <> '') AS "complianceCleared"`, + Array.of(BLANK_CHARS, COMPLIANCE_ACCOUNT_ADDRESS), + ); + + if (Number(rows.at(0)?.debugCleared) !== 1 || Number(rows.at(0)?.complianceCleared) !== 1) { + throw new Error('PRD staff-name backfill did not reach the required state for both staff accounts'); + } + } + + async down() { + // No-op: a granted clearance is not auto-revoked here; removal requires a separate reviewed, + // audited revocation so an unrelated rollback cannot silently erase an identity grant. + } +}; diff --git a/src/subdomains/generic/user/models/user/__tests__/backfill-compliance-and-debug-staff-verified-names.migration.spec.ts b/src/subdomains/generic/user/models/user/__tests__/backfill-compliance-and-debug-staff-verified-names.migration.spec.ts new file mode 100644 index 0000000000..916e1746d5 --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/backfill-compliance-and-debug-staff-verified-names.migration.spec.ts @@ -0,0 +1,425 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'backfill_compliance_and_debug_staff_verified_names_spec'; +const STAFF_NAME_ENV = 'STAFF_VERIFIED_NAME_395822'; +const DEBUG_ACCOUNT_ID = 395822; +const COMPLIANCE_ACCOUNT_ID = 777001; +const COMPLIANCE_ADDRESS = '0xBB922dB5F637aAfdc54b1509b231cc07461fb608'; +const OTHER_ACCOUNT_ID = 111222; +const OTHER_ACCOUNT_NAME = 'Other Cleared Staff'; + +let BackfillComplianceAndDebugStaffVerifiedNames: new () => { + up(queryRunner: QueryRunner): Promise; + down(): Promise; +}; + +function setEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +describe('BackfillComplianceAndDebugStaffVerifiedNames migration (SQL content)', () => { + const originalEnvironment = process.env.ENVIRONMENT; + const originalStaffName = process.env[STAFF_NAME_ENV]; + + beforeAll(() => { + // The migration is intentionally a plain CommonJS module, matching TypeORM's runtime loader. + // eslint-disable-next-line @typescript-eslint/no-require-imports + BackfillComplianceAndDebugStaffVerifiedNames = require('../../../../../../../migration/1785742000000-BackfillComplianceAndDebugStaffVerifiedNames'); + }); + + afterEach(() => { + setEnv('ENVIRONMENT', originalEnvironment); + setEnv(STAFF_NAME_ENV, originalStaffName); + }); + + it.each([['dev'], ['loc'], ['staging'], [undefined]])( + 'up() issues no queries when ENVIRONMENT is %s', + async (environment) => { + setEnv('ENVIRONMENT', environment); + setEnv(STAFF_NAME_ENV, undefined); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner as unknown as QueryRunner); + + expect(queryRunner.query).not.toHaveBeenCalled(); + }, + ); + + it.each([[undefined], [''], [' ']])( + 'fails before issuing SQL when the PRD deployment variable is %p', + async (staffName) => { + process.env.ENVIRONMENT = 'prd'; + setEnv(STAFF_NAME_ENV, staffName); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await expect( + new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner as unknown as QueryRunner), + ).rejects.toThrow(`${STAFF_NAME_ENV} is required`); + expect(queryRunner.query).not.toHaveBeenCalled(); + }, + ); + + it('issues one parameterized, audited update on PRD and never inlines the name', async () => { + process.env.ENVIRONMENT = 'prd'; + process.env[STAFF_NAME_ENV] = ' Test Staff Name '; + const queryRunner = { + query: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ debugCleared: 1, complianceCleared: 1 }]), + }; + + await new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner as unknown as QueryRunner); + + expect(queryRunner.query).toHaveBeenCalledTimes(2); + const [sql, parameters] = queryRunner.query.mock.calls[0]; + expect(parameters[0]).toBe('Test Staff Name'); + expect(parameters[2]).toBe(COMPLIANCE_ADDRESS); + expect(sql).toContain('INSERT INTO "log"'); + expect(sql).toContain("'StaffVerifiedNameBackfill'"); + expect(sql).toContain("'previousVerifiedName'"); + expect(sql).toContain("'nextVerifiedName'"); + expect(sql).toContain('FOR UPDATE'); + expect(sql).toContain('EXISTS (SELECT 1 FROM "audit")'); + expect(sql).toContain('SET "verifiedName" = $1::varchar, "updated" = now()'); + expect(sql).toContain(String(DEBUG_ACCOUNT_ID)); + // The address reaches the SQL only as a parameter, and the match must not hinge on the EIP-55 + // casing the app rendered when the address was transcribed. + expect(sql).not.toContain(COMPLIANCE_ADDRESS); + expect(sql).toContain('LOWER("address") = LOWER($3::varchar)'); + expect(sql).not.toContain('Test Staff Name'); + + // The precondition must be the exact negation of the postcondition — otherwise a present-but-blank + // name is a state the update refuses to repair and the assertion refuses to accept, and the deploy + // dies on a row the migration itself could have repaired. + expect(sql).toContain("BTRIM(COALESCE(\"verifiedName\", ''), $2::varchar) = ''"); + expect(sql).toContain("'action', CASE WHEN \"needsBackfill\" THEN 'backfilled' ELSE 'keptExistingName' END"); + + const [postconditionSql, postconditionParameters] = queryRunner.query.mock.calls[1]; + expect(postconditionSql).toContain('AS "debugCleared"'); + expect(postconditionSql).toContain('AS "complianceCleared"'); + expect(postconditionSql).toContain('BTRIM("verifiedName", $1::varchar)'); + expect(postconditionSql).toContain('LOWER("address") = LOWER($2::varchar)'); + // The postcondition asserts the clearance predicate, not equality with the supplied name. + expect(postconditionSql).not.toContain('"verifiedName" = $'); + expect(postconditionParameters[0]).toBe(parameters[1]); + expect(postconditionParameters[1]).toBe(COMPLIANCE_ADDRESS); + }); + + it.each([ + [{ debugCleared: 0, complianceCleared: 1 }], + [{ debugCleared: 1, complianceCleared: 0 }], + [{ debugCleared: 0, complianceCleared: 0 }], + ])('rejects when an account does not reach the cleared state (%o)', async (counts) => { + process.env.ENVIRONMENT = 'prd'; + process.env[STAFF_NAME_ENV] = 'Test Staff Name'; + const queryRunner = { + query: jest.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([counts]), + }; + + await expect( + new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner as unknown as QueryRunner), + ).rejects.toThrow('did not reach the required state for both staff accounts'); + }); + + it('down() deliberately performs no rollback', async () => { + const migration = new BackfillComplianceAndDebugStaffVerifiedNames(); + + expect(migration.down).toHaveLength(0); + await expect(migration.down()).resolves.toBeUndefined(); + }); +}); + +describeDb('BackfillComplianceAndDebugStaffVerifiedNames migration (real Postgres)', () => { + const originalEnvironment = process.env.ENVIRONMENT; + const originalStaffName = process.env[STAFF_NAME_ENV]; + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + // The migration is intentionally a plain CommonJS module, matching TypeORM's runtime loader. + // eslint-disable-next-line @typescript-eslint/no-require-imports + BackfillComplianceAndDebugStaffVerifiedNames = require('../../../../../../../migration/1785742000000-BackfillComplianceAndDebugStaffVerifiedNames'); + dataSource = new DataSource({ type: 'postgres', url: PG_URL }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + process.env.ENVIRONMENT = 'prd'; + process.env[STAFF_NAME_ENV] = 'Test Staff Name'; + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.query(`CREATE SCHEMA "${SCHEMA}"`); + await queryRunner.query(`SET search_path TO "${SCHEMA}"`); + await queryRunner.query(` + CREATE TABLE "user_data" ( + "id" integer PRIMARY KEY, + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "verifiedName" varchar(256) + ) + `); + await queryRunner.query(` + CREATE TABLE "user" ( + "id" SERIAL PRIMARY KEY, + "address" varchar(256) NOT NULL, + "userDataId" integer NOT NULL + ) + `); + await queryRunner.query(` + CREATE TABLE "log" ( + "id" SERIAL PRIMARY KEY, + "created" TIMESTAMP NOT NULL DEFAULT now(), + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "system" varchar(256) NOT NULL, + "subsystem" varchar(256) NOT NULL, + "severity" varchar(256) NOT NULL, + "message" text NOT NULL + ) + `); + }); + + afterEach(async () => { + setEnv('ENVIRONMENT', originalEnvironment); + setEnv(STAFF_NAME_ENV, originalStaffName); + if (queryRunner.isTransactionActive) await queryRunner.rollbackTransaction(); + await queryRunner.query(`SET search_path TO public`); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.release(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + // The non-target account carries a cleared name on purpose: an unscoped postcondition would then + // count it too, so the per-account assertions only hold while they stay pinned to their targets. + async function insertAccounts( + debugName: string | null = null, + complianceName: string | null = null, + complianceAddress: string = COMPLIANCE_ADDRESS, + ): Promise { + await queryRunner.query( + `INSERT INTO "user_data" ("id", "updated", "verifiedName") + VALUES (${DEBUG_ACCOUNT_ID}, TIMESTAMP '2000-01-01', $1), + (${COMPLIANCE_ACCOUNT_ID}, TIMESTAMP '2000-01-01', $2), + (${OTHER_ACCOUNT_ID}, TIMESTAMP '2000-01-01', $3)`, + [debugName, complianceName, OTHER_ACCOUNT_NAME], + ); + await queryRunner.query( + `INSERT INTO "user" ("address", "userDataId") + VALUES ($1, ${COMPLIANCE_ACCOUNT_ID}), ('0x0000000000000000000000000000000000000001', ${OTHER_ACCOUNT_ID})`, + [complianceAddress], + ); + } + + async function readAccounts(): Promise<{ id: number; verifiedName: string | null }[]> { + return queryRunner.query(`SELECT "id", "verifiedName" FROM "user_data" ORDER BY "id"`); + } + + it('backfills both targets, updates their timestamps, and records one before/after audit row', async () => { + await insertAccounts(); + + await new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner); + + const users = (await queryRunner.query( + `SELECT "id", "verifiedName", "updated" > TIMESTAMP '2000-01-01' AS "wasUpdated" + FROM "user_data" ORDER BY "id"`, + )) as { id: number; verifiedName: string | null; wasUpdated: boolean }[]; + expect(users).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME, wasUpdated: false }, + { id: DEBUG_ACCOUNT_ID, verifiedName: 'Test Staff Name', wasUpdated: true }, + { id: COMPLIANCE_ACCOUNT_ID, verifiedName: 'Test Staff Name', wasUpdated: true }, + ]); + + const logs = (await queryRunner.query( + `SELECT "message" FROM "log" WHERE "system" = 'User' AND "subsystem" = 'StaffVerifiedNameBackfill'`, + )) as { message: string }[]; + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0].message)).toEqual([ + { + userDataId: DEBUG_ACCOUNT_ID, + previousVerifiedName: null, + nextVerifiedName: 'Test Staff Name', + action: 'backfilled', + }, + { + userDataId: COMPLIANCE_ACCOUNT_ID, + previousVerifiedName: null, + nextVerifiedName: 'Test Staff Name', + action: 'backfilled', + }, + ]); + }); + + it('is idempotent and does not append another audit row on a second run', async () => { + await insertAccounts(); + const migration = new BackfillComplianceAndDebugStaffVerifiedNames(); + + await migration.up(queryRunner); + await migration.up(queryRunner); + + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { count: number }[]; + expect(logCount[0].count).toBe(1); + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: DEBUG_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + { id: COMPLIANCE_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + ]); + }); + + it('resolves the compliance account no matter which casing of the address is stored', async () => { + await insertAccounts(null, null, COMPLIANCE_ADDRESS.toLowerCase()); + + await new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: DEBUG_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + { id: COMPLIANCE_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + ]); + }); + + // `BlankChars` is defined as every character `String.prototype.trim()` strips, so derive that set from + // the runtime instead of restating it, and assert the migration's duplicated copy repairs a name built + // from all of them at once. A copy that lost a code point — the drift the migration's own comment warns + // about — would leave such a name unrepaired and still report success, because the postcondition + // shares the drifted constant and reads the residual character as non-blank. The migration cannot + // self-detect this; that is why the test asserts the repaired state rather than a rejection. + it('repairs a name built from every character trim() strips, pinning the duplicated BlankChars', async () => { + const blankChars = Array.from({ length: 0x10000 }, (_, code) => String.fromCharCode(code)).filter( + (char) => char.trim() === '', + ); + expect(blankChars.length).toBeGreaterThan(20); // sanity: the derivation actually found them + await insertAccounts(blankChars.join(''), blankChars.join('')); + + await new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: DEBUG_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + { id: COMPLIANCE_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + ]); + }); + + // A blank name clears no account — the gate's predicate is BTRIM-based, not IS NOT NULL. Repairing it + // is the whole point of widening the precondition: a name of a single tab would otherwise be a row the + // migration refuses to fix and then refuses to accept, taking the boot down with it. + it.each([['\t'], [' '], ['\u00a0'], ['\ufeff']])('repairs a blank verifiedName (%j)', async (blank) => { + await insertAccounts(blank, blank); + + await new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: DEBUG_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + { id: COMPLIANCE_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + ]); + }); + + // An identity-verified path may have written a different, valid name to one of the accounts. That + // account is cleared, so the migration must leave it alone and must NOT fail the deploy — but the + // divergence between the reviewed value and the deployed one must not be silent. The other account + // still gets repaired in the same run. + it('keeps an existing verified name, repairs the other account, and records both actions', async () => { + await insertAccounts(null, 'Existing Verified Name'); + + await expect(new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner)).resolves.toBeUndefined(); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: DEBUG_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + { id: COMPLIANCE_ACCOUNT_ID, verifiedName: 'Existing Verified Name' }, + ]); + const logs = (await queryRunner.query(`SELECT "message" FROM "log"`)) as { message: string }[]; + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0].message)).toEqual([ + { + userDataId: DEBUG_ACCOUNT_ID, + previousVerifiedName: null, + nextVerifiedName: 'Test Staff Name', + action: 'backfilled', + }, + { + userDataId: COMPLIANCE_ACCOUNT_ID, + previousVerifiedName: 'Existing Verified Name', + nextVerifiedName: 'Existing Verified Name', + action: 'keptExistingName', + }, + ]); + }); + + // One target present, one absent: the migration repairs the half it can reach, then the per-account + // postcondition throws over the missing one. Read INSIDE the transaction — the partial repair and its + // audit row are visible there, and it is exactly this state that the surrounding 'all'-mode + // transaction discards on PRD. After the rollback the same assertions would hold vacuously. + it('rejects when no user row carries the compliance address; the partial debug repair never commits', async () => { + await queryRunner.query( + `INSERT INTO "user_data" ("id", "verifiedName") + VALUES (${DEBUG_ACCOUNT_ID}, NULL), (${OTHER_ACCOUNT_ID}, $1)`, + [OTHER_ACCOUNT_NAME], + ); + await queryRunner.startTransaction(); + + await expect(new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner)).rejects.toThrow( + 'did not reach the required state for both staff accounts', + ); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: DEBUG_ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + ]); + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { count: number }[]; + expect(logCount[0].count).toBe(1); + + await queryRunner.rollbackTransaction(); + }); + + it('rejects when the debug account row is absent; the partial compliance repair never commits', async () => { + await queryRunner.query(`INSERT INTO "user_data" ("id", "verifiedName") VALUES (${COMPLIANCE_ACCOUNT_ID}, NULL)`); + await queryRunner.query(`INSERT INTO "user" ("address", "userDataId") VALUES ($1, ${COMPLIANCE_ACCOUNT_ID})`, [ + COMPLIANCE_ADDRESS, + ]); + await queryRunner.startTransaction(); + + await expect(new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner)).rejects.toThrow( + 'did not reach the required state for both staff accounts', + ); + + expect(await readAccounts()).toEqual([{ id: COMPLIANCE_ACCOUNT_ID, verifiedName: 'Test Staff Name' }]); + + await queryRunner.rollbackTransaction(); + }); + + it('changes nothing when a trigger suppresses the audit insert', async () => { + await insertAccounts(); + await queryRunner.query(` + CREATE FUNCTION suppress_log_insert() RETURNS trigger AS $fn$ + BEGIN + RETURN NULL; + END; + $fn$ LANGUAGE plpgsql + `); + await queryRunner.query(` + CREATE TRIGGER suppress_log_insert_trigger + BEFORE INSERT ON "log" + FOR EACH ROW + EXECUTE FUNCTION suppress_log_insert() + `); + + await expect(new BackfillComplianceAndDebugStaffVerifiedNames().up(queryRunner)).rejects.toThrow( + 'did not reach the required state for both staff accounts', + ); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: DEBUG_ACCOUNT_ID, verifiedName: null }, + { id: COMPLIANCE_ACCOUNT_ID, verifiedName: null }, + ]); + const logCount = (await queryRunner.query(`SELECT count(*)::int AS "count" FROM "log"`)) as { count: number }[]; + expect(logCount[0].count).toBe(0); + }); +});