diff --git a/docs/staff-kyc-clearance.md b/docs/staff-kyc-clearance.md index cb437b9a52..6e8cad6fca 100644 --- a/docs/staff-kyc-clearance.md +++ b/docs/staff-kyc-clearance.md @@ -32,12 +32,43 @@ be reviewed and reproducible like any other schema/data change. 1. Open a PR against the api repo that adds a new migration under `migration/` (never modify an existing one — `api-migration-check.yaml` blocks that). The migration runs a guarded, idempotent - `UPDATE user_data SET "verifiedName" = … WHERE id = … AND "verifiedName" IS NULL`. + `UPDATE user_data SET "verifiedName" = … WHERE id = … AND BTRIM(COALESCE("verifiedName", ''), ) = ''`. + The closing assertion must check the **clearance predicate itself** — that + `… WHERE id = … AND BTRIM("verifiedName", ) <> ''` yields exactly one row — and never + equality with the supplied name. The goal state is a cleared account, not a particular spelling, and + an equality assertion fails whichever precondition it is paired with: against a blankness + precondition (the shape of the first such migration) it throws when an identity-verified path wrote a + different but perfectly valid name in the meantime; against its own negation it does the opposite and + silently overwrites that name. + + The precondition must then be the **exact negation** of that assertion **including the NULL case** — + `BTRIM(COALESCE("verifiedName", ''), ) = ''`, not + `BTRIM("verifiedName", ) = ''`. Without the `COALESCE`, NULL yields NULL rather than + true, so the ordinary un-backfilled account is neither repaired nor accepted. Use the same + `BlankChars` set as `StaffKycClearanceService`. A narrower `"verifiedName" IS NULL` precondition has + the mirror-image flaw: it leaves 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. + + The `UPDATE` must be coupled to a durable before/after audit row — a `log` insert (`system` `'User'`, + `subsystem` `'StaffVerifiedNameBackfill'`, `severity` `'Info'`) in the same statement via a + data-modifying CTE, with the update conditioned on `EXISTS (SELECT 1 FROM "audit")` so the column + cannot change unaudited. `verifiedName` is PII; CONTRIBUTING treats unaudited mutation of it as + blocking. + + Every one of these mistakes is boot-fatal rather than merely wrong: `migrationsTransactionMode` + defaults to `all`, so the throw rolls back the whole release's migration batch and fails + `DataSource.initialize()`. + 2. **A real person's name is PII and must not be hard-coded in this public repo.** The migration - reads the value from a deployment secret (e.g. `process.env.STAFF_VERIFIED_NAME_`), set in the - production config, and applies it only when present. A non-personal service designation (for a - machine account that cannot complete a personal identification) is not PII and may appear inline. - The concrete name↔account mapping is recorded in the private operations repo, not here. + reads the value from a deployment variable (e.g. `process.env.STAFF_VERIFIED_NAME_`), set in the + production config. On PRD the variable is **mandatory**: the migration throws when it is absent, + rather than silently recording a no-op — so the value has to be live in the production environment + before the migration is merged to `develop`. `auto-release-pr.yaml` keeps a `develop` → `main` + release PR open continuously — every `develop` push either opens one or lands on the one already + open — so `develop` is the last point at which the order can still be arranged. A non-personal + service designation (for a machine account that cannot complete a personal + identification) is not PII and may appear inline. The concrete name↔account mapping is recorded in + the private operations repo, not here. 3. Merge the PR through the normal review, then release `develop → main`. The production deploy runs pending migrations automatically when `SQL_MIGRATE=true` (see `migrationsRun` in `src/config/config.ts`) — no shell access to the database is involved. diff --git a/migration/1785635000000-BackfillDebugStaffVerifiedName.js b/migration/1785635000000-BackfillDebugStaffVerifiedName.js new file mode 100644 index 0000000000..f06e21c438 --- /dev/null +++ b/migration/1785635000000-BackfillDebugStaffVerifiedName.js @@ -0,0 +1,101 @@ +/** + * @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'; + +/** + * PRD-only backfill for the remaining staff account gated out by the staff-clearance rule (#4395 → + * #4572). The earlier backfill (#4574) covered two accounts; user data 403938 (the Debug account) was + * not among them and still fails every elevated endpoint with STAFF_KYC_REQUIRED. + * + * No plaintext personal name lives in this file: the value is read from the deployment variable + * STAFF_VERIFIED_NAME_403938, which is mandatory on PRD so TypeORM cannot record a partial/no-op + * migration when it is missing. The update is idempotent (only touches a null-or-blank verifiedName) + * and coupled to a durable before/after audit entry. Guarded to prd; a no-op elsewhere. + * + * The closing assertion checks the clearance predicate itself 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 BackfillDebugStaffVerifiedName1785635000000 { + name = 'BackfillDebugStaffVerifiedName1785635000000'; + + async up(queryRunner) { + if (process.env.ENVIRONMENT !== 'prd') return; + + const verifiedName = process.env.STAFF_VERIFIED_NAME_403938?.trim(); + if (!verifiedName) throw new Error('STAFF_VERIFIED_NAME_403938 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 "target" AS ( + SELECT "id", + "verifiedName" AS "previousVerifiedName", + BTRIM(COALESCE("verifiedName", ''), $2::varchar) = '' AS "needsBackfill" + FROM "user_data" + WHERE "id" = 403938 + FOR UPDATE + ), + "noteworthy" AS ( + SELECT "id", "previousVerifiedName", "needsBackfill" + FROM "target" + 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 "target" t + WHERE ud."id" = t."id" AND t."needsBackfill" AND EXISTS (SELECT 1 FROM "audit")`, + Array.of(verifiedName, BLANK_CHARS), + ); + + const rows = await queryRunner.query( + `SELECT count(*)::int AS "clearedCount" + FROM "user_data" + WHERE "id" = 403938 AND BTRIM("verifiedName", $1::varchar) <> ''`, + Array.of(BLANK_CHARS), + ); + + if (Number(rows.at(0)?.clearedCount) !== 1) { + throw new Error('PRD staff-name backfill did not reach the required state for user data 403938'); + } + } + + 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-debug-staff-verified-name.migration.spec.ts b/src/subdomains/generic/user/models/user/__tests__/backfill-debug-staff-verified-name.migration.spec.ts new file mode 100644 index 0000000000..19b419f74a --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/backfill-debug-staff-verified-name.migration.spec.ts @@ -0,0 +1,353 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'backfill_debug_staff_verified_name_spec'; +const STAFF_NAME_ENV = 'STAFF_VERIFIED_NAME_403938'; +const ACCOUNT_ID = 403938; +const OTHER_ACCOUNT_ID = 111222; +const OTHER_ACCOUNT_NAME = 'Other Cleared Staff'; + +let BackfillDebugStaffVerifiedName: 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('BackfillDebugStaffVerifiedName 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 + BackfillDebugStaffVerifiedName = require('../../../../../../../migration/1785635000000-BackfillDebugStaffVerifiedName'); + }); + + 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 BackfillDebugStaffVerifiedName().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 BackfillDebugStaffVerifiedName().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([{ clearedCount: 1 }]), + }; + + await new BackfillDebugStaffVerifiedName().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(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(ACCOUNT_ID)); + 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 "clearedCount"'); + expect(postconditionSql).toContain('BTRIM("verifiedName", $1::varchar)'); + // The postcondition asserts the clearance predicate, not equality with the supplied name. + expect(postconditionSql).not.toContain('"verifiedName" = $'); + expect(postconditionParameters[0]).toBe(parameters[1]); + }); + + it('rejects when the account does not reach the cleared state', async () => { + process.env.ENVIRONMENT = 'prd'; + process.env[STAFF_NAME_ENV] = 'Test Staff Name'; + const queryRunner = { + query: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ clearedCount: 0 }]), + }; + + await expect(new BackfillDebugStaffVerifiedName().up(queryRunner as unknown as QueryRunner)).rejects.toThrow( + `did not reach the required state for user data ${ACCOUNT_ID}`, + ); + }); + + it('down() deliberately performs no rollback', async () => { + const migration = new BackfillDebugStaffVerifiedName(); + + expect(migration.down).toHaveLength(0); + await expect(migration.down()).resolves.toBeUndefined(); + }); +}); + +describeDb('BackfillDebugStaffVerifiedName 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 + BackfillDebugStaffVerifiedName = require('../../../../../../../migration/1785635000000-BackfillDebugStaffVerifiedName'); + 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 "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 `count(*) = 1` only holds while the assertion stays pinned to ACCOUNT_ID. + async function insertAccounts( + targetName: string | null = null, + otherName: string | null = OTHER_ACCOUNT_NAME, + ): Promise { + await queryRunner.query( + `INSERT INTO "user_data" ("id", "updated", "verifiedName") + VALUES (${ACCOUNT_ID}, TIMESTAMP '2000-01-01', $1), (${OTHER_ACCOUNT_ID}, TIMESTAMP '2000-01-01', $2)`, + [targetName, otherName], + ); + } + + async function readAccounts(): Promise<{ id: number; verifiedName: string | null }[]> { + return queryRunner.query(`SELECT "id", "verifiedName" FROM "user_data" ORDER BY "id"`); + } + + it('backfills the target, updates its timestamp, and records one before/after audit row', async () => { + await insertAccounts(); + + await new BackfillDebugStaffVerifiedName().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: 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: 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 BackfillDebugStaffVerifiedName(); + + 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: 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('')); + + await new BackfillDebugStaffVerifiedName().up(queryRunner); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: 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); + + await new BackfillDebugStaffVerifiedName().up(queryRunner); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: ACCOUNT_ID, verifiedName: 'Test Staff Name' }, + ]); + const logs = (await queryRunner.query(`SELECT "message" FROM "log"`)) as { message: string }[]; + expect(JSON.parse(logs[0].message)).toEqual([ + { + userDataId: ACCOUNT_ID, + previousVerifiedName: blank, + nextVerifiedName: 'Test Staff Name', + action: 'backfilled', + }, + ]); + }); + + // The deliberate deviation from #4574: an identity-verified path may have written a different, valid + // name. 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. + it('leaves an existing verified name untouched, records the divergence, and still succeeds', async () => { + await insertAccounts('Existing Verified Name'); + + await expect(new BackfillDebugStaffVerifiedName().up(queryRunner)).resolves.toBeUndefined(); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: 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: ACCOUNT_ID, + previousVerifiedName: 'Existing Verified Name', + nextVerifiedName: 'Existing Verified Name', + action: 'keptExistingName', + }, + ]); + }); + + it('rejects when the target row is absent, without writing an audit row first', async () => { + await queryRunner.query(`INSERT INTO "user_data" ("id", "verifiedName") VALUES (${OTHER_ACCOUNT_ID}, $1)`, [ + OTHER_ACCOUNT_NAME, + ]); + await queryRunner.startTransaction(); + + await expect(new BackfillDebugStaffVerifiedName().up(queryRunner)).rejects.toThrow( + `did not reach the required state for user data ${ACCOUNT_ID}`, + ); + + // Read INSIDE the transaction: after the rollback every write is gone regardless, so the same + // assertion afterwards would hold even if the migration had written an audit row. + const logs = (await queryRunner.query(`SELECT "message" FROM "log"`)) as { message: string }[]; + expect(logs).toHaveLength(0); + + 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 BackfillDebugStaffVerifiedName().up(queryRunner)).rejects.toThrow( + `did not reach the required state for user data ${ACCOUNT_ID}`, + ); + + expect(await readAccounts()).toEqual([ + { id: OTHER_ACCOUNT_ID, verifiedName: OTHER_ACCOUNT_NAME }, + { id: 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); + }); +});