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:
parent
f5ac179623
commit
5ac248660a
5 changed files with 273 additions and 13 deletions
46
scripts/test/migrate-roundtrip.mjs
Normal file
46
scripts/test/migrate-roundtrip.mjs
Normal 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}`);
|
||||
117
scripts/test/provision-pg.mjs
Normal file
117
scripts/test/provision-pg.mjs
Normal 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();
|
||||
22
tests/e2e/evidence/n5-after.txt
Normal file
22
tests/e2e/evidence/n5-after.txt
Normal 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
|
||||
30
tests/e2e/evidence/n5-before.txt
Normal file
30
tests/e2e/evidence/n5-before.txt
Normal 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
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue