krona-staff/scripts/test/provision-pg.mjs
grok 5ac248660a 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 пустой.
2026-09-26 15:18:31 +07:00

117 lines
5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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();