test(db): стенд PostgreSQL с мигратором без SUPERUSER

Зачем: аудит показал, что 156 зелёных тестов на fakeDatabase не заметили,
что создание сотрудника, подтверждение смены и расчёт зарплаты всегда
падают на реальной PostgreSQL. Чтобы тесты и CI проверяли реальный SQL,
нужен воспроизводимый стенд с ролями как в pilot, но с минимальными
привилегиями:

- scripts/test/provision-pg.mjs: bootstrap-админ (только как DBA/entrypoint)
  создаёт krona_migrator (NOSUPERUSER NOCREATEDB NOCREATEROLE, владелец БД и
  схемы public) и krona_staff_app (NOSUPERUSER NOBYPASSRLS NOINHERIT); пароли
  crypto.randomBytes, пишутся только в env-файл 0600, в вывод не попадают.
  scripts/create-app-role.mjs здесь не используется, т.к. без SUPERUSER он
  падает (находка N6, продуктовый код не трогаем).
- scripts/test/migrate-roundtrip.mjs: up -> откат всех миграций -> up ->
  повторный up должен быть no-op; ролью мигратора, как требует AGENTS.md.

Проверено на PostgreSQL 17.11: 11 миграций применены, откачены, применены
повторно, третий up пустой.
This commit is contained in:
grok 2026-09-26 14:21:47 +07:00
parent f5ac179623
commit 5ac248660a
5 changed files with 273 additions and 13 deletions

View file

@ -0,0 +1,46 @@
#!/usr/bin/env node
// up -> откат ВСЕХ миграций по одной -> up -> повторный up (идемпотентность).
// Запускается ролью мигратора (DATABASE_MIGRATION_URL), без SUPERUSER.
import { spawnSync } from 'node:child_process';
function run(direction) {
const result = spawnSync(process.execPath, ['scripts/migrate.mjs', direction], {
env: { ...process.env, ALLOW_MIGRATION_DOWN: direction === 'down' ? 'true' : 'false' },
encoding: 'utf8',
});
process.stdout.write(result.stdout);
process.stderr.write(result.stderr);
if (result.status !== 0) {
console.error(`migrate ${direction} failed with exit ${result.status}`);
process.exit(result.status ?? 1);
}
return result.stdout;
}
const mode = process.argv[2] ?? 'roundtrip';
console.log('== migrate up (1)');
const first = run('up');
if (mode === 'up') process.exit(0);
const applied = (first.match(/^applied /gm) ?? []).length;
console.log(`== rollback all (applied now: ${applied})`);
let rolledBack = 0;
for (let i = 0; i < 1000; i += 1) {
const out = run('down');
if (/no migration to roll back/.test(out)) break;
rolledBack += 1;
}
console.log(`== rolled back ${rolledBack} migrations`);
console.log('== migrate up (2)');
const second = run('up');
const reapplied = (second.match(/^applied /gm) ?? []).length;
console.log('== migrate up (3, must be no-op)');
const third = run('up');
if ((third.match(/^applied /gm) ?? []).length !== 0) {
console.error('third up applied migrations again: not idempotent');
process.exit(1);
}
if (rolledBack === 0 || reapplied !== rolledBack) {
console.error(`rollback/reapply mismatch: rolledBack=${rolledBack} reapplied=${reapplied}`);
process.exit(1);
}
console.log(`== roundtrip OK: rolledBack=${rolledBack} reapplied=${reapplied}`);

View file

@ -0,0 +1,117 @@
#!/usr/bin/env node
// Тестовый стенд PostgreSQL, повторяющий роли pilot, но без суперпользователя-мигратора:
// * bootstrap-админ (E2E_PG_ADMIN_URL) используется ТОЛЬКО здесь, как DBA/entrypoint образа;
// * krona_migrator — NOSUPERUSER NOCREATEDB NOCREATEROLE, владелец БД и схемы public;
// * krona_staff_app — NOSUPERUSER NOBYPASSRLS NOINHERIT, права выдают миграции 0003+.
// Пароли генерируются crypto.randomBytes и пишутся только в env-файл с правами 0600.
// Печатаются имена ролей/БД, но не пароли.
import { randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import pg from 'pg';
const args = new Map();
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i], process.argv[i + 1]);
const adminUrl = process.env.E2E_PG_ADMIN_URL;
const envFile = args.get('--out');
const database = args.get('--db') ?? 'krona_staff_e2e';
if (!adminUrl || !envFile) {
console.error(
'Usage: E2E_PG_ADMIN_URL=... node scripts/test/provision-pg.mjs --out <env-path> [--db name]',
);
process.exit(2);
}
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(database)) throw new Error('Invalid database name');
export async function provision() {
const migratorPassword = randomBytes(24).toString('hex');
const appPassword = randomBytes(24).toString('hex');
const admin = new pg.Client({
connectionString: adminUrl,
application_name: 'krona-e2e-provision',
});
await admin.connect();
try {
await admin.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_migrator') THEN
CREATE ROLE krona_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN
CREATE ROLE krona_staff_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
END IF;
END $$`);
const alterMigrator = await admin.query(
"SELECT format('ALTER ROLE krona_migrator WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS', $1::text) AS sql",
[migratorPassword],
);
await admin.query(alterMigrator.rows[0].sql);
const alterApp = await admin.query(
"SELECT format('ALTER ROLE krona_staff_app WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', $1::text) AS sql",
[appPassword],
);
await admin.query(alterApp.rows[0].sql);
await admin.query(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,
[database],
);
await admin.query(`DROP DATABASE IF EXISTS ${database}`);
await admin.query(
`CREATE DATABASE ${database} OWNER krona_migrator ENCODING 'UTF8' TEMPLATE template0`,
);
} finally {
await admin.end();
}
const dbAdminUrl = new URL(adminUrl);
dbAdminUrl.pathname = `/${database}`;
const dbAdmin = new pg.Client({ connectionString: dbAdminUrl.toString() });
await dbAdmin.connect();
try {
await dbAdmin.query('ALTER SCHEMA public OWNER TO krona_migrator');
await dbAdmin.query('REVOKE CREATE ON SCHEMA public FROM PUBLIC');
await dbAdmin.query(`REVOKE ALL ON DATABASE ${database} FROM PUBLIC`);
await dbAdmin.query(`GRANT CONNECT ON DATABASE ${database} TO krona_staff_app`);
} finally {
await dbAdmin.end();
}
const base = new URL(adminUrl);
const url = (user, password) => {
const u = new URL(base.toString());
u.username = user;
u.password = password;
u.pathname = `/${database}`;
return u.toString();
};
const migratorUrl = url('krona_migrator', migratorPassword);
const appUrl = url('krona_staff_app', appPassword);
const lines = [
`DATABASE_MIGRATION_URL=${migratorUrl}`,
`DATABASE_TEST_MIGRATION_URL=${migratorUrl}`,
`DATABASE_TEST_URL=${appUrl}`,
`DATABASE_URL=${appUrl}`,
`POSTGRES_APP_PASSWORD=${appPassword}`,
'',
];
writeFileSync(envFile, lines.join('\n'), { mode: 0o600 });
// Проверяем атрибуты ролей и печатаем их (без паролей) как доказательство.
const check = new pg.Client({ connectionString: migratorUrl });
await check.connect();
try {
const roles = await check.query(
`SELECT rolname, rolsuper, rolcreaterole, rolcreatedb, rolbypassrls
FROM pg_roles WHERE rolname IN ('krona_migrator','krona_staff_app') ORDER BY rolname`,
);
const version = await check.query('SHOW server_version');
console.log(
`provisioned database=${database} server_version=${version.rows[0].server_version}`,
);
for (const r of roles.rows) {
console.log(
`role=${r.rolname} super=${r.rolsuper} createrole=${r.rolcreaterole} createdb=${r.rolcreatedb} bypassrls=${r.rolbypassrls}`,
);
}
} finally {
await check.end();
}
}
await provision();

View file

@ -0,0 +1,22 @@
== run 1: DATABASE_TEST_URL=<krona_staff_app@127.0.0.1:55433/krona_staff_e2e> DATABASE_TEST_MIGRATION_URL=<krona_migrator@...> npx vitest run tests/integration/tenant-isolation.test.ts
RUN v5.0.1 /workspace/krona-staff
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 14:20:56
Duration 275ms (tests 63%, transform 23%, import 12%, worker 1%)
exit=0
== run 2: DATABASE_TEST_URL=<krona_staff_app@127.0.0.1:55433/krona_staff_e2e> DATABASE_TEST_MIGRATION_URL=<krona_migrator@...> npx vitest run tests/integration/tenant-isolation.test.ts
RUN v5.0.1 /workspace/krona-staff
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 14:20:57
Duration 260ms (tests 62%, transform 23%, import 13%, worker 2%)
exit=0

View file

@ -0,0 +1,30 @@
== DATABASE_TEST_URL=<krona_staff_app@127.0.0.1:55433/krona_staff_e2e> npx vitest run tests/integration/tenant-isolation.test.ts (база 778ba2d, до исправления)
RUN v5.0.1 /workspace/krona-staff
❯ tests/integration/tenant-isolation.test.ts (1 test) 114ms
⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL tests/integration/tenant-isolation.test.ts > PostgreSQL tenant isolation
error: permission denied for table staff_employees
❯ node_modules/pg/lib/client.js:694:17
❯ tests/integration/tenant-isolation.test.ts:40:7
38| await client.query('DELETE FROM staff_outbox WHERE id = $1', [ev…
39| await client.query('DELETE FROM staff_professions WHERE id = $1'…
40| await client.query('DELETE FROM staff_employees WHERE id = $1', …
| ^
41| });
42| await database.close();
❯ Database.tenantTransaction src/infrastructure/database.ts:30:22
❯ tests/integration/tenant-isolation.test.ts:31:5
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 passed (1)
Start at 14:20:47
Duration 265ms (tests 62%, transform 23%, import 14%, worker 1%)
exit=1

View file

@ -1,3 +1,4 @@
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Database } from '../../src/infrastructure/database.js'; import { Database } from '../../src/infrastructure/database.js';
import { newUlid, parseUlid } from '../../src/core/ids.js'; import { newUlid, parseUlid } from '../../src/core/ids.js';
@ -5,6 +6,23 @@ import { newUlid, parseUlid } from '../../src/core/ids.js';
const connectionString = process.env.DATABASE_TEST_URL; const connectionString = process.env.DATABASE_TEST_URL;
const describeDatabase = connectionString ? describe : describe.skip; const describeDatabase = connectionString ? describe : describe.skip;
// Роль приложения намеренно не имеет DELETE на staff_employees и ряде других таблиц,
// поэтому очистка идёт соединением роли мигратора (владельца таблиц). FORCE RLS действует
// и на владельца, поэтому внутри транзакции выставляется app.tenant_id.
const migrationConnectionString =
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
const cleanupTables = [
'staff_sync_device_checkpoints',
'staff_sync_command_inbox',
'staff_sync_change_log',
'staff_sync_devices',
'staff_notification_deliveries',
'staff_push_subscriptions',
'staff_outbox',
'staff_professions',
'staff_employees',
] as const;
describeDatabase('PostgreSQL tenant isolation', () => { describeDatabase('PostgreSQL tenant isolation', () => {
const database = new Database(connectionString!); const database = new Database(connectionString!);
const tenantA = parseUlid('01J0000000000000000000000A'); const tenantA = parseUlid('01J0000000000000000000000A');
@ -20,26 +38,51 @@ describeDatabase('PostgreSQL tenant isolation', () => {
const changeIdA = newUlid(); const changeIdA = newUlid();
const commandIdA = newUlid(); const commandIdA = newUlid();
async function cleanupAsMigrator() {
if (!migrationConnectionString) {
throw new Error(
'DATABASE_TEST_MIGRATION_URL (или DATABASE_MIGRATION_URL) обязателен вместе с DATABASE_TEST_URL: очистка выполняется ролью мигратора',
);
}
const migrator = new pg.Client({
connectionString: migrationConnectionString,
application_name: 'krona-staff-test-cleanup',
});
await migrator.connect();
try {
for (const tenant of [tenantA, tenantB]) {
await migrator.query('BEGIN');
try {
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenant]);
for (const table of cleanupTables) {
await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenant]);
}
await migrator.query('COMMIT');
} catch (error) {
await migrator.query('ROLLBACK');
throw error;
}
}
} finally {
await migrator.end();
}
}
beforeAll(async () => { beforeAll(async () => {
const role = await database.pool.query( const role = await database.pool.query(
'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user', 'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user',
); );
expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false }); expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
// Остатки прошлого упавшего прогона иначе ломают уникальные ключи (EMP-PUSH-1, DEV-A).
await cleanupAsMigrator();
}); });
afterAll(async () => { afterAll(async () => {
await database.tenantTransaction(tenantA, actorA, async (client) => { try {
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [tenantA]); await cleanupAsMigrator();
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]); } finally {
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]);
await client.query('DELETE FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]);
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]);
await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]);
await client.query('DELETE FROM staff_employees WHERE id = $1', [employeeIdA]);
});
await database.close(); await database.close();
}
}); });
it('prevents reads and writes across two tenants', async () => { it('prevents reads and writes across two tenants', async () => {
@ -253,7 +296,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
}); });
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) => const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]), client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [
deviceIdA,
]),
); );
expect(cpVisibleToB.rowCount).toBe(0); expect(cpVisibleToB.rowCount).toBe(0);