Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
96 changes: 96 additions & 0 deletions migration/1785550000000-GrantSupportRoleOnDev.js
Original file line number Diff line number Diff line change
@@ -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
`);
}
};
Original file line number Diff line number Diff line change
@@ -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<void>;
down(queryRunner: QueryRunner): Promise<void>;
};

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<number> {
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<string> {
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');
});
});