From 5ac248660a4d77ab147a0a75b84d4b5d849cf14a Mon Sep 17 00:00:00 2001 From: grok Date: Sat, 26 Sep 2026 14:21:47 +0700 Subject: [PATCH] =?UTF-8?q?test(db):=20=D1=81=D1=82=D0=B5=D0=BD=D0=B4=20Po?= =?UTF-8?q?stgreSQL=20=D1=81=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=20=D0=B1=D0=B5=D0=B7=20SUPERUSER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: аудит показал, что 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 пустой. --- scripts/test/migrate-roundtrip.mjs | 46 ++++++++ scripts/test/provision-pg.mjs | 117 +++++++++++++++++++++ tests/e2e/evidence/n5-after.txt | 22 ++++ tests/e2e/evidence/n5-before.txt | 30 ++++++ tests/integration/tenant-isolation.test.ts | 71 ++++++++++--- 5 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 scripts/test/migrate-roundtrip.mjs create mode 100644 scripts/test/provision-pg.mjs create mode 100644 tests/e2e/evidence/n5-after.txt create mode 100644 tests/e2e/evidence/n5-before.txt diff --git a/scripts/test/migrate-roundtrip.mjs b/scripts/test/migrate-roundtrip.mjs new file mode 100644 index 0000000..7567631 --- /dev/null +++ b/scripts/test/migrate-roundtrip.mjs @@ -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}`); diff --git a/scripts/test/provision-pg.mjs b/scripts/test/provision-pg.mjs new file mode 100644 index 0000000..2db7ae5 --- /dev/null +++ b/scripts/test/provision-pg.mjs @@ -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 [--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(); diff --git a/tests/e2e/evidence/n5-after.txt b/tests/e2e/evidence/n5-after.txt new file mode 100644 index 0000000..0255aa4 --- /dev/null +++ b/tests/e2e/evidence/n5-after.txt @@ -0,0 +1,22 @@ +== run 1: DATABASE_TEST_URL= DATABASE_TEST_MIGRATION_URL= 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= DATABASE_TEST_MIGRATION_URL= 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 diff --git a/tests/e2e/evidence/n5-before.txt b/tests/e2e/evidence/n5-before.txt new file mode 100644 index 0000000..d2fa1d7 --- /dev/null +++ b/tests/e2e/evidence/n5-before.txt @@ -0,0 +1,30 @@ +== DATABASE_TEST_URL= 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 diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts index a954a3a..d806d26 100644 --- a/tests/integration/tenant-isolation.test.ts +++ b/tests/integration/tenant-isolation.test.ts @@ -1,3 +1,4 @@ +import pg from 'pg'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Database } from '../../src/infrastructure/database.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 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', () => { const database = new Database(connectionString!); const tenantA = parseUlid('01J0000000000000000000000A'); @@ -20,26 +38,51 @@ describeDatabase('PostgreSQL tenant isolation', () => { const changeIdA = 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 () => { const role = await database.pool.query( 'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user', ); expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false }); + // Остатки прошлого упавшего прогона иначе ломают уникальные ключи (EMP-PUSH-1, DEV-A). + await cleanupAsMigrator(); }); afterAll(async () => { - await database.tenantTransaction(tenantA, actorA, async (client) => { - await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [tenantA]); - await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]); - 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(); + try { + await cleanupAsMigrator(); + } finally { + await database.close(); + } }); 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) => - 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);