diff --git a/.env.example b/.env.example index 5e7045785c..f07d258351 100644 --- a/.env.example +++ b/.env.example @@ -165,6 +165,11 @@ PIMLICO_API_KEY= TEST_SEED= TEST_WALLET= +# Throwaway Postgres for the migration specs (*.migration.spec.ts). Each spec creates +# and drops its own schema, so the database must not hold anything worth keeping. +# The real-Postgres suites skip when unset; CI sets it in the sharded test job. +MIGRATION_TEST_PG= + ETH_WALLET_ADDRESS= ETH_WALLET_PRIVATE_KEY=xxx diff --git a/migration/1785550000000-GrantSupportRoleOnDev.js b/migration/1785550000000-GrantSupportRoleOnDev.js new file mode 100644 index 0000000000..c00e78f665 --- /dev/null +++ b/migration/1785550000000-GrantSupportRoleOnDev.js @@ -0,0 +1,96 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class GrantSupportRoleOnDev1785550000000 { + name = 'GrantSupportRoleOnDev1785550000000'; + + /** + * Grant Support role to 0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8 on DEV only, + * and only when the user currently has the User role. + * Update and audit log insert run in one statement so a failed audit aborts the role change + * (fail-closed, CONTRIBUTING auditable mutations). + * + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // DEV-only: this grant is scoped to the DEV environment. Whether the same + // address exists elsewhere is unchecked; never elevate roles outside DEV. + if (process.env.ENVIRONMENT !== 'dev') return; + + await queryRunner.query(` + WITH updated AS ( + UPDATE "user" + SET "role" = 'Support' + WHERE LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8') + AND "role" = 'User' + RETURNING id + ) + INSERT INTO "log" ("system", "subsystem", "severity", "message", "category") + SELECT + 'User', + 'GrantSupportRoleOnDev', + 'Info', + jsonb_build_object( + 'migration', 'GrantSupportRoleOnDev1785550000000', + 'direction', 'up', + 'affectedCount', count(*), + 'userIds', string_agg(id::text, ','), + 'fromRole', 'User', + 'toRole', 'Support' + )::text, + 'up' + FROM updated + `); + } + + /** + * Revert: restore User role for the same address, only if currently Support and only when + * up() actually promoted a row (audit log affectedCount > 0). + * Update and audit log insert run in one statement so a failed audit aborts the role change + * (fail-closed, CONTRIBUTING auditable mutations). + * + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + // DEV-only: mirror the up() environment gate so down() never touches other envs. + if (process.env.ENVIRONMENT !== 'dev') return; + + await queryRunner.query(` + WITH updated AS ( + UPDATE "user" + SET "role" = 'User' + WHERE LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8') + AND "role" = 'Support' + AND EXISTS ( + SELECT 1 FROM "log" + WHERE "system" = 'User' + AND "subsystem" = 'GrantSupportRoleOnDev' + AND "category" = 'up' + AND ("message"::jsonb ->> 'affectedCount')::int > 0 + ) + RETURNING id + ) + INSERT INTO "log" ("system", "subsystem", "severity", "message", "category") + SELECT + 'User', + 'GrantSupportRoleOnDev', + 'Info', + jsonb_build_object( + 'migration', 'GrantSupportRoleOnDev1785550000000', + 'direction', 'down', + 'affectedCount', count(*), + 'userIds', string_agg(id::text, ','), + 'fromRole', 'Support', + 'toRole', 'User' + )::text, + 'down' + FROM updated + `); + } +}; diff --git a/src/subdomains/generic/user/models/user/__tests__/grant-support-role-on-dev.migration.spec.ts b/src/subdomains/generic/user/models/user/__tests__/grant-support-role-on-dev.migration.spec.ts new file mode 100644 index 0000000000..67f8498130 --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/grant-support-role-on-dev.migration.spec.ts @@ -0,0 +1,284 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'grant_support_role_on_dev_spec'; + +const TARGET_ADDRESS = '0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8'; +const OTHER_ADDRESS = '0x1111111111111111111111111111111111111111'; + +const NON_DEV_ENVIRONMENTS: (string | undefined)[] = ['prd', 'stg', 'loc', '', undefined]; + +function setEnvironment(value: string | undefined): void { + if (value === undefined) delete process.env.ENVIRONMENT; + else process.env.ENVIRONMENT = value; +} + +let GrantSupportRoleOnDev: new () => { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +function normalizeSql(sql: string): string { + return sql.replace(/\s+/g, ' ').trim(); +} + +describe('GrantSupportRoleOnDev migration (SQL content)', () => { + const originalEnv = process.env.ENVIRONMENT; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + GrantSupportRoleOnDev = require('../../../../../../../migration/1785550000000-GrantSupportRoleOnDev'); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnv; + } + }); + + it.each(NON_DEV_ENVIRONMENTS)('up() issues no queries when ENVIRONMENT is %p (not dev)', async (value) => { + setEnvironment(value); + const migration = new GrantSupportRoleOnDev(); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await migration.up(queryRunner as unknown as QueryRunner); + + expect(queryRunner.query.mock.calls).toHaveLength(0); + }); + + it.each(NON_DEV_ENVIRONMENTS)('down() issues no queries when ENVIRONMENT is %p (not dev)', async (value) => { + setEnvironment(value); + const migration = new GrantSupportRoleOnDev(); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await migration.down(queryRunner as unknown as QueryRunner); + + expect(queryRunner.query.mock.calls).toHaveLength(0); + }); + + it('up() grants Support only for the target address currently in User role on dev', async () => { + process.env.ENVIRONMENT = 'dev'; + const migration = new GrantSupportRoleOnDev(); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await migration.up(queryRunner as unknown as QueryRunner); + + const calls = queryRunner.query.mock.calls as [string, unknown[]?][]; + expect(calls).toHaveLength(1); + for (const call of calls) { + expect(call).toHaveLength(1); + } + + const sql = calls[0][0]; + const normalized = normalizeSql(sql); + + expect(sql).toContain(`SET "role" = 'Support'`); + // AND-conjunction pinned as one fragment so OR-mutants fail (not three separate toContain). + expect(normalized).toContain( + normalizeSql( + `LOWER("address") = LOWER('${TARGET_ADDRESS}') + AND "role" = 'User'`, + ), + ); + expect(sql).toContain(`INSERT INTO "log"`); + expect(sql).toContain(`'GrantSupportRoleOnDev'`); + expect(sql).toContain(`'direction', 'up'`); + }); + + it('down() restores User only for the target address currently in Support role on dev', async () => { + process.env.ENVIRONMENT = 'dev'; + const migration = new GrantSupportRoleOnDev(); + const queryRunner = { query: jest.fn(async (_sql: string) => []) }; + + await migration.down(queryRunner as unknown as QueryRunner); + + const calls = queryRunner.query.mock.calls as [string, unknown[]?][]; + expect(calls).toHaveLength(1); + for (const call of calls) { + expect(call).toHaveLength(1); + } + + const sql = calls[0][0]; + const normalized = normalizeSql(sql); + + expect(sql).toContain(`SET "role" = 'User'`); + // AND-conjunction pinned as one fragment so OR-mutants fail (not three separate toContain). + expect(normalized).toContain( + normalizeSql( + `LOWER("address") = LOWER('${TARGET_ADDRESS}') + AND "role" = 'Support'`, + ), + ); + expect(sql).toContain(`("message"::jsonb ->> 'affectedCount')::int > 0`); + expect(sql).toContain(`INSERT INTO "log"`); + expect(sql).toContain(`'direction', 'down'`); + }); +}); + +describeDb('GrantSupportRoleOnDev migration (real Postgres)', () => { + let dataSource: DataSource; + let queryRunner: QueryRunner; + const originalEnv = process.env.ENVIRONMENT; + + beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + GrantSupportRoleOnDev = require('../../../../../../../migration/1785550000000-GrantSupportRoleOnDev'); + dataSource = new DataSource({ type: 'postgres', url: PG_URL }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + process.env.ENVIRONMENT = 'dev'; + 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" ( + "id" SERIAL PRIMARY KEY, + "address" varchar(256), + "role" varchar(256) NOT NULL + ) + `); + + await queryRunner.query(` + CREATE TABLE "log" ( + "id" SERIAL PRIMARY KEY, + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "created" TIMESTAMP NOT NULL DEFAULT now(), + "system" varchar(256) NOT NULL, + "subsystem" varchar(256) NOT NULL, + "severity" varchar(256) NOT NULL, + "message" text NOT NULL, + "category" varchar(256), + "valid" boolean + ) + `); + }); + + afterEach(async () => { + if (originalEnv === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnv; + } + 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(); + }); + + async function insertUser(address: string, role: string): Promise { + const rows = await queryRunner.query(`INSERT INTO "user" ("address", "role") VALUES ($1, $2) RETURNING "id"`, [ + address, + role, + ]); + return rows[0].id as number; + } + + async function getRole(id: number): Promise { + const rows = await queryRunner.query(`SELECT "role" FROM "user" WHERE "id" = $1`, [id]); + return rows[0].role as string; + } + + async function getLogs(): Promise< + { system: string; subsystem: string; severity: string; message: string; category: string }[] + > { + return queryRunner.query( + `SELECT "system", "subsystem", "severity", "message", "category" FROM "log" ORDER BY "id"`, + ); + } + + it('up() promotes the target User address to Support and leaves other User addresses alone', async () => { + const targetId = await insertUser(TARGET_ADDRESS, 'User'); + const otherId = await insertUser(OTHER_ADDRESS, 'User'); + const migration = new GrantSupportRoleOnDev(); + + await migration.up(queryRunner); + + expect(await getRole(targetId)).toBe('Support'); + expect(await getRole(otherId)).toBe('User'); + }); + + it('up() leaves the target address with Compliance role untouched', async () => { + const targetId = await insertUser(TARGET_ADDRESS, 'Compliance'); + const migration = new GrantSupportRoleOnDev(); + + await migration.up(queryRunner); + + expect(await getRole(targetId)).toBe('Compliance'); + }); + + it('up() writes exactly one log row with correct affectedCount', async () => { + const targetId = await insertUser(TARGET_ADDRESS, 'User'); + const migration = new GrantSupportRoleOnDev(); + + await migration.up(queryRunner); + + const logs = await getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].system).toBe('User'); + expect(logs[0].subsystem).toBe('GrantSupportRoleOnDev'); + expect(logs[0].severity).toBe('Info'); + expect(logs[0].category).toBe('up'); + + const message = JSON.parse(logs[0].message) as { + migration: string; + direction: string; + affectedCount: number; + userIds: string; + fromRole: string; + toRole: string; + }; + expect(message.migration).toBe('GrantSupportRoleOnDev1785550000000'); + expect(message.direction).toBe('up'); + expect(Number(message.affectedCount)).toBe(1); + expect(message.userIds).toBe(String(targetId)); + expect(message.fromRole).toBe('User'); + expect(message.toRole).toBe('Support'); + }); + + it('down() after up() restores User', async () => { + const targetId = await insertUser(TARGET_ADDRESS, 'User'); + const migration = new GrantSupportRoleOnDev(); + + await migration.up(queryRunner); + expect(await getRole(targetId)).toBe('Support'); + + await migration.down(queryRunner); + expect(await getRole(targetId)).toBe('User'); + }); + + it('down() without a prior promoting up() leaves an existing Support role untouched', async () => { + const targetId = await insertUser(TARGET_ADDRESS, 'Support'); + const migration = new GrantSupportRoleOnDev(); + + await migration.down(queryRunner); + + expect(await getRole(targetId)).toBe('Support'); + + const logs = await getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].category).toBe('down'); + const message = JSON.parse(logs[0].message) as { affectedCount: number }; + expect(Number(message.affectedCount)).toBe(0); + }); + + it('up() matches the target address case-insensitively', async () => { + const targetId = await insertUser(TARGET_ADDRESS.toLowerCase(), 'User'); + const migration = new GrantSupportRoleOnDev(); + + await migration.up(queryRunner); + + expect(await getRole(targetId)).toBe('Support'); + }); +});