From 96ce38d0bef896b60601f9e55f2414b29e213777 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 29 Jul 2026 09:50:53 +0200 Subject: [PATCH 1/9] feat(auth): grant the Support role on DEV for support-dashboard measurements --- .../1785311300000-GrantSupportRoleOnDev.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 migration/1785311300000-GrantSupportRoleOnDev.js diff --git a/migration/1785311300000-GrantSupportRoleOnDev.js b/migration/1785311300000-GrantSupportRoleOnDev.js new file mode 100644 index 0000000000..9c884d0cdb --- /dev/null +++ b/migration/1785311300000-GrantSupportRoleOnDev.js @@ -0,0 +1,48 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class GrantSupportRoleOnDev1785311300000 { + name = 'GrantSupportRoleOnDev1785311300000'; + + /** + * Grant Support role to 0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8 on DEV only, + * and only when the user currently has the User role. + * + * @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(` + UPDATE "user" + SET "role" = 'Support' + WHERE LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8') + AND "role" = 'User' + `); + } + + /** + * Revert: restore User role for the same address, only if currently Support. + * + * @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(` + UPDATE "user" + SET "role" = 'User' + WHERE LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8') + AND "role" = 'Support' + `); + } +}; From 96ac70df572a2191f9cbd66c8510d53cd98380f7 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 29 Jul 2026 10:02:39 +0200 Subject: [PATCH 2/9] test(auth): pin the environment gate and ownership guard of the Support-role migration --- ...rant-support-role-on-dev.migration.spec.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/subdomains/generic/user/models/user/__tests__/grant-support-role-on-dev.migration.spec.ts 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..02d5507c71 --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/grant-support-role-on-dev.migration.spec.ts @@ -0,0 +1,81 @@ +import { QueryRunner } from 'typeorm'; + +let GrantSupportRoleOnDev: new () => { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +describe('GrantSupportRoleOnDev migration (SQL content)', () => { + const originalEnv = process.env.ENVIRONMENT; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + GrantSupportRoleOnDev = require('../../../../../../../migration/1785311300000-GrantSupportRoleOnDev'); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnv; + } + }); + + it('up() issues no queries when ENVIRONMENT is not dev', async () => { + process.env.ENVIRONMENT = 'prd'; + 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('down() issues no queries when ENVIRONMENT is not dev', async () => { + process.env.ENVIRONMENT = 'prd'; + 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]; + expect(sql).toContain(`SET "role" = 'Support'`); + expect(sql).toContain(`LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8')`); + expect(sql).toContain(`"role" = 'User'`); + }); + + 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]; + expect(sql).toContain(`SET "role" = 'User'`); + expect(sql).toContain(`LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8')`); + expect(sql).toContain(`"role" = 'Support'`); + }); +}); From d31d915153b8b7f0f6b7e55d17c714fe8573fac2 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 11:00:40 +0200 Subject: [PATCH 3/9] fix(auth): record the Support-role transition in an audit log row CONTRIBUTING requires that an overwritten value stay reconstructible from the database: when a field changed, from which value, and to which. The WHERE predicate only pinned the previous value, leaving no record of when the migration ran or whether it matched a row at all. Both directions now run the UPDATE and the audit insert as a single statement, so a failing audit write aborts the role change (fail-closed). The row is written even when no user matched, which is what makes "ran and changed nothing" distinguishable from "never ran". It carries the migration name, direction, affected count, affected user ids and both roles, and no address or other personal data. down() is now a true inverse: it only demotes when the up() audit row reports a non-zero affected count, so a wallet that already held Support before this migration keeps it. --- .../1785311300000-GrantSupportRoleOnDev.js | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/migration/1785311300000-GrantSupportRoleOnDev.js b/migration/1785311300000-GrantSupportRoleOnDev.js index 9c884d0cdb..9cdcec1d70 100644 --- a/migration/1785311300000-GrantSupportRoleOnDev.js +++ b/migration/1785311300000-GrantSupportRoleOnDev.js @@ -13,6 +13,8 @@ module.exports = class GrantSupportRoleOnDev1785311300000 { /** * 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 */ @@ -22,15 +24,36 @@ module.exports = class GrantSupportRoleOnDev1785311300000 { if (process.env.ENVIRONMENT !== 'dev') return; await queryRunner.query(` - UPDATE "user" - SET "role" = 'Support' - WHERE LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8') - AND "role" = 'User' + 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 + 'Auth', + 'GrantSupportRoleOnDev', + 'Info', + jsonb_build_object( + 'migration', 'GrantSupportRoleOnDev1785311300000', + '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. + * 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 */ @@ -39,10 +62,35 @@ module.exports = class GrantSupportRoleOnDev1785311300000 { if (process.env.ENVIRONMENT !== 'dev') return; await queryRunner.query(` - UPDATE "user" - SET "role" = 'User' - WHERE LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8') - AND "role" = 'Support' + WITH updated AS ( + UPDATE "user" + SET "role" = 'User' + WHERE LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8') + AND "role" = 'Support' + AND EXISTS ( + SELECT 1 FROM "log" + WHERE "system" = 'Auth' + AND "subsystem" = 'GrantSupportRoleOnDev' + AND "category" = 'up' + AND ("message"::jsonb ->> 'affectedCount')::int > 0 + ) + RETURNING id + ) + INSERT INTO "log" ("system", "subsystem", "severity", "message", "category") + SELECT + 'Auth', + 'GrantSupportRoleOnDev', + 'Info', + jsonb_build_object( + 'migration', 'GrantSupportRoleOnDev1785311300000', + 'direction', 'down', + 'affectedCount', count(*), + 'userIds', string_agg(id::text, ','), + 'fromRole', 'Support', + 'toRole', 'User' + )::text, + 'down' + FROM updated `); } }; From a8464cfbc2c6b6444760ba122c98172f2afbd6db Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 11:01:01 +0200 Subject: [PATCH 4/9] test(auth): pin the role-grant predicate against a real Postgres The previous spec asserted address, target role and source role as three separate toContain calls, so a predicate using OR instead of AND would have passed while matching every User row. The conjunction is now asserted as one contiguous fragment, and the suite runs the migration against a throwaway Postgres schema, following the MIGRATION_TEST_PG pattern already used by the custody and realunit migration specs. The behavioural cases cover what string matching cannot: a second User address stays untouched, a target address holding Compliance is left alone, the audit row carries the right affected count, down() after up() restores User, down() without a promoting up() leaves an existing Support role in place, and the address match is case-insensitive. The environment gate is now table-driven over prd, stg, loc, the empty string and an unset value, instead of asserting "not dev" while only ever setting prd. --- ...rant-support-role-on-dev.migration.spec.ts | 276 +++++++++++++++++- 1 file changed, 267 insertions(+), 9 deletions(-) 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 index 02d5507c71..d96a7aaa49 100644 --- 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 @@ -1,10 +1,21 @@ -import { QueryRunner } from 'typeorm'; +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'; 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; @@ -21,8 +32,39 @@ describe('GrantSupportRoleOnDev migration (SQL content)', () => { } }); - it('up() issues no queries when ENVIRONMENT is not dev', async () => { - process.env.ENVIRONMENT = 'prd'; + it.each([ + { + label: 'prd', + set: () => { + process.env.ENVIRONMENT = 'prd'; + }, + }, + { + label: 'stg', + set: () => { + process.env.ENVIRONMENT = 'stg'; + }, + }, + { + label: 'loc', + set: () => { + process.env.ENVIRONMENT = 'loc'; + }, + }, + { + label: 'empty string', + set: () => { + process.env.ENVIRONMENT = ''; + }, + }, + { + label: 'unset', + set: () => { + delete process.env.ENVIRONMENT; + }, + }, + ])('up() issues no queries when ENVIRONMENT is $label (not dev)', async ({ set }) => { + set(); const migration = new GrantSupportRoleOnDev(); const queryRunner = { query: jest.fn(async (_sql: string) => []) }; @@ -31,8 +73,39 @@ describe('GrantSupportRoleOnDev migration (SQL content)', () => { expect(queryRunner.query.mock.calls).toHaveLength(0); }); - it('down() issues no queries when ENVIRONMENT is not dev', async () => { - process.env.ENVIRONMENT = 'prd'; + it.each([ + { + label: 'prd', + set: () => { + process.env.ENVIRONMENT = 'prd'; + }, + }, + { + label: 'stg', + set: () => { + process.env.ENVIRONMENT = 'stg'; + }, + }, + { + label: 'loc', + set: () => { + process.env.ENVIRONMENT = 'loc'; + }, + }, + { + label: 'empty string', + set: () => { + process.env.ENVIRONMENT = ''; + }, + }, + { + label: 'unset', + set: () => { + delete process.env.ENVIRONMENT; + }, + }, + ])('down() issues no queries when ENVIRONMENT is $label (not dev)', async ({ set }) => { + set(); const migration = new GrantSupportRoleOnDev(); const queryRunner = { query: jest.fn(async (_sql: string) => []) }; @@ -55,9 +128,19 @@ describe('GrantSupportRoleOnDev migration (SQL content)', () => { } const sql = calls[0][0]; + const normalized = normalizeSql(sql); + expect(sql).toContain(`SET "role" = 'Support'`); - expect(sql).toContain(`LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8')`); - expect(sql).toContain(`"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" = '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 () => { @@ -74,8 +157,183 @@ describe('GrantSupportRoleOnDev migration (SQL content)', () => { } const sql = calls[0][0]; + const normalized = normalizeSql(sql); + expect(sql).toContain(`SET "role" = 'User'`); - expect(sql).toContain(`LOWER("address") = LOWER('0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8')`); - expect(sql).toContain(`"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" = '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/1785311300000-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('Auth'); + 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('GrantSupportRoleOnDev1785311300000'); + 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'); }); }); From 811c8ff6bef8d2125561dc24be9b81aab24a2906 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 12:13:14 +0200 Subject: [PATCH 5/9] chore(auth): raise the migration timestamp above the current develop head develop has moved on to 1785460000000 since this branch was opened, so the migration was back-dated. TypeORM would still run it, but the immutability check blocks any rename once the file is merged, so the timestamp can only be corrected now. Renames the file, the class, the name property, the migration name carried in both audit log rows and the spec's require path and assertion. --- ...oleOnDev.js => 1785470000000-GrantSupportRoleOnDev.js} | 8 ++++---- .../__tests__/grant-support-role-on-dev.migration.spec.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) rename migration/{1785311300000-GrantSupportRoleOnDev.js => 1785470000000-GrantSupportRoleOnDev.js} (93%) diff --git a/migration/1785311300000-GrantSupportRoleOnDev.js b/migration/1785470000000-GrantSupportRoleOnDev.js similarity index 93% rename from migration/1785311300000-GrantSupportRoleOnDev.js rename to migration/1785470000000-GrantSupportRoleOnDev.js index 9cdcec1d70..91ccdb38f0 100644 --- a/migration/1785311300000-GrantSupportRoleOnDev.js +++ b/migration/1785470000000-GrantSupportRoleOnDev.js @@ -7,8 +7,8 @@ * @class * @implements {MigrationInterface} */ -module.exports = class GrantSupportRoleOnDev1785311300000 { - name = 'GrantSupportRoleOnDev1785311300000'; +module.exports = class GrantSupportRoleOnDev1785470000000 { + name = 'GrantSupportRoleOnDev1785470000000'; /** * Grant Support role to 0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8 on DEV only, @@ -37,7 +37,7 @@ module.exports = class GrantSupportRoleOnDev1785311300000 { 'GrantSupportRoleOnDev', 'Info', jsonb_build_object( - 'migration', 'GrantSupportRoleOnDev1785311300000', + 'migration', 'GrantSupportRoleOnDev1785470000000', 'direction', 'up', 'affectedCount', count(*), 'userIds', string_agg(id::text, ','), @@ -82,7 +82,7 @@ module.exports = class GrantSupportRoleOnDev1785311300000 { 'GrantSupportRoleOnDev', 'Info', jsonb_build_object( - 'migration', 'GrantSupportRoleOnDev1785311300000', + 'migration', 'GrantSupportRoleOnDev1785470000000', 'direction', 'down', 'affectedCount', count(*), 'userIds', string_agg(id::text, ','), 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 index d96a7aaa49..60984d0242 100644 --- 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 @@ -21,7 +21,7 @@ describe('GrantSupportRoleOnDev migration (SQL content)', () => { beforeAll(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports - GrantSupportRoleOnDev = require('../../../../../../../migration/1785311300000-GrantSupportRoleOnDev'); + GrantSupportRoleOnDev = require('../../../../../../../migration/1785470000000-GrantSupportRoleOnDev'); }); afterEach(() => { @@ -180,7 +180,7 @@ describeDb('GrantSupportRoleOnDev migration (real Postgres)', () => { beforeAll(async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports - GrantSupportRoleOnDev = require('../../../../../../../migration/1785311300000-GrantSupportRoleOnDev'); + GrantSupportRoleOnDev = require('../../../../../../../migration/1785470000000-GrantSupportRoleOnDev'); dataSource = new DataSource({ type: 'postgres', url: PG_URL }); await dataSource.initialize(); }); @@ -294,7 +294,7 @@ describeDb('GrantSupportRoleOnDev migration (real Postgres)', () => { fromRole: string; toRole: string; }; - expect(message.migration).toBe('GrantSupportRoleOnDev1785311300000'); + expect(message.migration).toBe('GrantSupportRoleOnDev1785470000000'); expect(message.direction).toBe('up'); expect(Number(message.affectedCount)).toBe(1); expect(message.userIds).toBe(String(targetId)); From faf1c955a6ce8a2f1705173fb6aa2b18b76986b6 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 12:26:27 +0200 Subject: [PATCH 6/9] docs(config): document the migration test database variable The migration specs have used MIGRATION_TEST_PG since #4418 without it appearing in .env.example, so a fresh checkout gives no hint that the real-Postgres suites exist or why they skip. This branch adds another such spec, so the gap is documented here. --- .env.example | 5 +++++ 1 file changed, 5 insertions(+) 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 From e3176272f2ae89bb5e8720f0aa9a2adf66a15534 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 14:26:13 +0200 Subject: [PATCH 7/9] chore(auth): move the migration off a timestamp now taken on develop AddTradingOrderCreatedIndex landed on develop carrying 1785470000000, the same prefix this migration had, which leaves the order between the two undefined. ClearDevUserSignatures has since raised the highest timestamp on develop to 1785500000000. Moves to 1785550000000, following the rounded values develop uses rather than a real creation time, and renames the class, the name property, the migration name in both audit rows and the spec's require path and assertion. --- ...oleOnDev.js => 1785550000000-GrantSupportRoleOnDev.js} | 8 ++++---- .../__tests__/grant-support-role-on-dev.migration.spec.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) rename migration/{1785470000000-GrantSupportRoleOnDev.js => 1785550000000-GrantSupportRoleOnDev.js} (93%) diff --git a/migration/1785470000000-GrantSupportRoleOnDev.js b/migration/1785550000000-GrantSupportRoleOnDev.js similarity index 93% rename from migration/1785470000000-GrantSupportRoleOnDev.js rename to migration/1785550000000-GrantSupportRoleOnDev.js index 91ccdb38f0..10446fd32f 100644 --- a/migration/1785470000000-GrantSupportRoleOnDev.js +++ b/migration/1785550000000-GrantSupportRoleOnDev.js @@ -7,8 +7,8 @@ * @class * @implements {MigrationInterface} */ -module.exports = class GrantSupportRoleOnDev1785470000000 { - name = 'GrantSupportRoleOnDev1785470000000'; +module.exports = class GrantSupportRoleOnDev1785550000000 { + name = 'GrantSupportRoleOnDev1785550000000'; /** * Grant Support role to 0xB6cA05F0e3e71B1C5568BD423A6682dc78469Ae8 on DEV only, @@ -37,7 +37,7 @@ module.exports = class GrantSupportRoleOnDev1785470000000 { 'GrantSupportRoleOnDev', 'Info', jsonb_build_object( - 'migration', 'GrantSupportRoleOnDev1785470000000', + 'migration', 'GrantSupportRoleOnDev1785550000000', 'direction', 'up', 'affectedCount', count(*), 'userIds', string_agg(id::text, ','), @@ -82,7 +82,7 @@ module.exports = class GrantSupportRoleOnDev1785470000000 { 'GrantSupportRoleOnDev', 'Info', jsonb_build_object( - 'migration', 'GrantSupportRoleOnDev1785470000000', + 'migration', 'GrantSupportRoleOnDev1785550000000', 'direction', 'down', 'affectedCount', count(*), 'userIds', string_agg(id::text, ','), 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 index 60984d0242..2083e0c177 100644 --- 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 @@ -21,7 +21,7 @@ describe('GrantSupportRoleOnDev migration (SQL content)', () => { beforeAll(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports - GrantSupportRoleOnDev = require('../../../../../../../migration/1785470000000-GrantSupportRoleOnDev'); + GrantSupportRoleOnDev = require('../../../../../../../migration/1785550000000-GrantSupportRoleOnDev'); }); afterEach(() => { @@ -180,7 +180,7 @@ describeDb('GrantSupportRoleOnDev migration (real Postgres)', () => { beforeAll(async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports - GrantSupportRoleOnDev = require('../../../../../../../migration/1785470000000-GrantSupportRoleOnDev'); + GrantSupportRoleOnDev = require('../../../../../../../migration/1785550000000-GrantSupportRoleOnDev'); dataSource = new DataSource({ type: 'postgres', url: PG_URL }); await dataSource.initialize(); }); @@ -294,7 +294,7 @@ describeDb('GrantSupportRoleOnDev migration (real Postgres)', () => { fromRole: string; toRole: string; }; - expect(message.migration).toBe('GrantSupportRoleOnDev1785470000000'); + expect(message.migration).toBe('GrantSupportRoleOnDev1785550000000'); expect(message.direction).toBe('up'); expect(Number(message.affectedCount)).toBe(1); expect(message.userIds).toBe(String(targetId)); From 6aef2cf27a9140bab7c63ea0bab9916d4bfe4f10 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 16:11:11 +0200 Subject: [PATCH 8/9] test(auth): collapse the environment-gate tables onto a shared value list Each of the ten non-dev cases carried its own set() closure, which spent eighty lines on what is a list of five values used twice. ClearDevUserSignatures writes the same table as it.each over the values themselves, so this follows the spec that sits next to it on develop. %p rather than %s, because the set here includes the empty string, and %s would render that case and an unset value as indistinguishable titles. No assertion changes: the contiguous AND-fragment checks, the remaining SQL assertions and the Postgres block are untouched, and the count stays at 18. Against the migration without the audit row 5 of them fail; the OR mutant in up() (3), the OR mutant in down() (2), LOWER() dropped in both directions (3), the down() ownership guard removed (2), affectedCount pinned to 0 (2), the environment gate inverted (17) and the single statement split into two queries (3) each turn the suite red. --- ...rant-support-role-on-dev.migration.spec.ts | 77 +++---------------- 1 file changed, 11 insertions(+), 66 deletions(-) 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 index 2083e0c177..4be80f8b3d 100644 --- 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 @@ -7,6 +7,13 @@ 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; @@ -32,39 +39,8 @@ describe('GrantSupportRoleOnDev migration (SQL content)', () => { } }); - it.each([ - { - label: 'prd', - set: () => { - process.env.ENVIRONMENT = 'prd'; - }, - }, - { - label: 'stg', - set: () => { - process.env.ENVIRONMENT = 'stg'; - }, - }, - { - label: 'loc', - set: () => { - process.env.ENVIRONMENT = 'loc'; - }, - }, - { - label: 'empty string', - set: () => { - process.env.ENVIRONMENT = ''; - }, - }, - { - label: 'unset', - set: () => { - delete process.env.ENVIRONMENT; - }, - }, - ])('up() issues no queries when ENVIRONMENT is $label (not dev)', async ({ set }) => { - set(); + 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) => []) }; @@ -73,39 +49,8 @@ describe('GrantSupportRoleOnDev migration (SQL content)', () => { expect(queryRunner.query.mock.calls).toHaveLength(0); }); - it.each([ - { - label: 'prd', - set: () => { - process.env.ENVIRONMENT = 'prd'; - }, - }, - { - label: 'stg', - set: () => { - process.env.ENVIRONMENT = 'stg'; - }, - }, - { - label: 'loc', - set: () => { - process.env.ENVIRONMENT = 'loc'; - }, - }, - { - label: 'empty string', - set: () => { - process.env.ENVIRONMENT = ''; - }, - }, - { - label: 'unset', - set: () => { - delete process.env.ENVIRONMENT; - }, - }, - ])('down() issues no queries when ENVIRONMENT is $label (not dev)', async ({ set }) => { - set(); + 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) => []) }; From ff46d0eca6b52b433a981f82d5e3ad0078ff8e7b Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 30 Jul 2026 16:21:58 +0200 Subject: [PATCH 9/9] fix(auth): name the audit rows after the table they record, not a domain The two comparable audit migrations on develop take the log system from the entity they mutate: FixFiatOutputValutaDateSerials writes 'FiatOutput' for fiat_output, ClearDevUserSignatures writes 'User' for user. This one wrote 'Auth', which appears nowhere else as a log system - the only two occurrences in the repo are a Swagger tag and a notification type. Moves both audit rows and the down() ownership guard to 'User'. The guard matters: it looks up the up() row by system, so leaving it on 'Auth' while up() wrote 'User' would keep down() from ever restoring the role. The down()-after-up() case covers that coupling and fails if the two drift apart. No change in blast radius either way, since every reader of the log table scopes to system 'LogService' and the cleanup job runs per configured pair. Pinning the value to 'Wrong' fails the audit-row test. --- migration/1785550000000-GrantSupportRoleOnDev.js | 6 +++--- .../__tests__/grant-support-role-on-dev.migration.spec.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/migration/1785550000000-GrantSupportRoleOnDev.js b/migration/1785550000000-GrantSupportRoleOnDev.js index 10446fd32f..c00e78f665 100644 --- a/migration/1785550000000-GrantSupportRoleOnDev.js +++ b/migration/1785550000000-GrantSupportRoleOnDev.js @@ -33,7 +33,7 @@ module.exports = class GrantSupportRoleOnDev1785550000000 { ) INSERT INTO "log" ("system", "subsystem", "severity", "message", "category") SELECT - 'Auth', + 'User', 'GrantSupportRoleOnDev', 'Info', jsonb_build_object( @@ -69,7 +69,7 @@ module.exports = class GrantSupportRoleOnDev1785550000000 { AND "role" = 'Support' AND EXISTS ( SELECT 1 FROM "log" - WHERE "system" = 'Auth' + WHERE "system" = 'User' AND "subsystem" = 'GrantSupportRoleOnDev' AND "category" = 'up' AND ("message"::jsonb ->> 'affectedCount')::int > 0 @@ -78,7 +78,7 @@ module.exports = class GrantSupportRoleOnDev1785550000000 { ) INSERT INTO "log" ("system", "subsystem", "severity", "message", "category") SELECT - 'Auth', + 'User', 'GrantSupportRoleOnDev', 'Info', jsonb_build_object( 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 index 4be80f8b3d..67f8498130 100644 --- 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 @@ -226,7 +226,7 @@ describeDb('GrantSupportRoleOnDev migration (real Postgres)', () => { const logs = await getLogs(); expect(logs).toHaveLength(1); - expect(logs[0].system).toBe('Auth'); + expect(logs[0].system).toBe('User'); expect(logs[0].subsystem).toBe('GrantSupportRoleOnDev'); expect(logs[0].severity).toBe('Info'); expect(logs[0].category).toBe('up');