Зачем: аудит показал, что 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 пустой.
46 lines
1.8 KiB
JavaScript
46 lines
1.8 KiB
JavaScript
#!/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}`);
|