// Глобальная подготовка e2e: реальная PostgreSQL 17, миграции ролью мигратора // (без SUPERUSER), реальный процесс сервера под ролью приложения, случайные секреты. import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { createWriteStream, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { TestProject } from 'vitest/node'; import { baseEnv, nodeWithTsx } from '../../../scripts/test/node-commands.mjs'; import { randomUlid, type E2EContext } from './context.js'; const repoRoot = path.resolve(import.meta.dirname, '../../..'); const evidenceDir = path.join(repoRoot, 'tests/e2e/evidence'); function parseEnvFile(file: string): Record { const result: Record = {}; for (const line of readFileSync(file, 'utf8').split('\n')) { const index = line.indexOf('='); if (index > 0) result[line.slice(0, index)] = line.slice(index + 1); } return result; } async function freePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; server.close(() => resolve(port)); }); }); } function runNode(args: string[], env: NodeJS.ProcessEnv, label: string) { const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, encoding: 'utf8' }); const output = `${result.stdout}${result.stderr}`; console.log(`[e2e] ${label}:\n${output.trim()}`); if (result.status !== 0) throw new Error(`${label} failed with exit ${result.status}`); return output; } function vapidEnv(): Record { const output = spawnSync(process.execPath, ['scripts/generate-vapid-keys.mjs'], { cwd: repoRoot, encoding: 'utf8', }).stdout; const env: Record = {}; for (const line of output.split('\n')) { const index = line.indexOf('='); if (index > 0) env[line.slice(0, index)] = line.slice(index + 1); } return env; } interface StartedServer { child: ChildProcess; baseUrl: string; } async function startServer(name: string, env: Record): Promise { const port = await freePort(); const logPath = path.join(evidenceDir, `server-${name}.txt`); const log = createWriteStream(logPath, { flags: 'w' }); const server = nodeWithTsx(['src/server.ts']); const child = spawn(server.command, server.args, { cwd: repoRoot, env: { ...baseEnv(), NODE_ENV: 'test', HOST: '127.0.0.1', PORT: String(port), LOG_LEVEL: 'info', STAFF_RUNTIME_MODE: 'standalone', ...env, }, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout?.pipe(log); child.stderr?.pipe(log); const baseUrl = `http://127.0.0.1:${port}`; const deadline = Date.now() + 60_000; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`server ${name} exited early with ${child.exitCode}; see ${logPath}`); } try { const response = await fetch(`${baseUrl}/api/v1/staff/ready`); if (response.status === 200) { console.log( `[e2e] server ${name} ready at ${baseUrl} (log: ${path.relative(repoRoot, logPath)})`, ); return { child, baseUrl }; } } catch { // сервер ещё не слушает порт } await new Promise((resolve) => setTimeout(resolve, 250)); } child.kill('SIGKILL'); throw new Error(`server ${name} did not become ready; see ${logPath}`); } /** * Откуда брать БД и роли. * 1. E2E_MIGRATOR_URL и E2E_APP_URL заданы — роли и пароли уже есть (общий кластер, * параллельная работа других): стенд их не создаёт и не меняет, БД берётся из URL. * 2. Иначе (по умолчанию) — bootstrap-админ E2E_PG_ADMIN_URL создаёт роли со * случайными паролями и пересоздаёт БД E2E_DATABASE_NAME (scripts/test/provision-pg.mjs). * adminDatabaseUrl — админ в той же БД (нужен только сценарию PILOT-REHOME): * E2E_DB_ADMIN_URL, либо E2E_PG_ADMIN_URL с именем БД стенда. */ function resolveDatabase(): { migrationUrl: string; appUrl: string; adminDatabaseUrl: string | null; } { const adminUrl = process.env.E2E_PG_ADMIN_URL; const withDatabase = (url: string, database: string) => { const result = new URL(url); result.pathname = `/${database}`; return result.toString(); }; const presetMigrator = process.env.E2E_MIGRATOR_URL; const presetApp = process.env.E2E_APP_URL; if (presetMigrator && presetApp) { const database = new URL(presetApp).pathname.slice(1); console.log( `[e2e] роли заданы E2E_MIGRATOR_URL/E2E_APP_URL: provision пропущен, БД ${database}`, ); return { migrationUrl: presetMigrator, appUrl: presetApp, adminDatabaseUrl: process.env.E2E_DB_ADMIN_URL ?? (adminUrl ? withDatabase(adminUrl, database) : null), }; } if (presetMigrator || presetApp) { throw new Error('E2E_MIGRATOR_URL и E2E_APP_URL задаются только вместе'); } if (!adminUrl) { throw new Error( 'E2E_PG_ADMIN_URL не задан: нужен bootstrap-админ PostgreSQL 17 (только для создания ролей и БД) ' + 'или готовые роли в E2E_MIGRATOR_URL + E2E_APP_URL', ); } const database = process.env.E2E_DATABASE_NAME ?? 'krona_staff_e2e'; const secretsDir = mkdtempSync(path.join(tmpdir(), 'krona-e2e-')); const envFile = path.join(secretsDir, 'db.env'); runNode( ['scripts/test/provision-pg.mjs', '--out', envFile, '--db', database], { ...process.env, E2E_PG_ADMIN_URL: adminUrl }, 'provision', ); const dbEnv = parseEnvFile(envFile); rmSync(secretsDir, { recursive: true, force: true }); if (!dbEnv.DATABASE_MIGRATION_URL || !dbEnv.DATABASE_URL) { throw new Error('provision did not produce connection URLs'); } return { migrationUrl: dbEnv.DATABASE_MIGRATION_URL, appUrl: dbEnv.DATABASE_URL, adminDatabaseUrl: process.env.E2E_DB_ADMIN_URL ?? withDatabase(adminUrl, database), }; } export default async function setup(project: TestProject) { mkdirSync(evidenceDir, { recursive: true }); const { migrationUrl, appUrl, adminDatabaseUrl } = resolveDatabase(); const migrationMode = process.env.E2E_MIGRATIONS ?? 'up'; runNode( ['scripts/test/migrate-roundtrip.mjs', migrationMode === 'roundtrip' ? 'roundtrip' : 'up'], { ...baseEnv(), DATABASE_MIGRATION_URL: migrationUrl }, `migrations (${migrationMode}) as krona_migrator`, ); const tenantId = randomUlid(); const actorId = randomUlid(); const terminalActorId = randomUlid(); const managerToken = randomBytes(32).toString('hex'); const terminalToken = randomBytes(32).toString('hex'); const common = { DATABASE_URL: appUrl, STANDALONE_TENANT_ID: tenantId, STANDALONE_ACTOR_ID: actorId, ...vapidEnv(), }; const secured = await startServer('secured', { ...common, STANDALONE_ACCESS: 'employee', STANDALONE_MANAGER_TOKEN: managerToken, STANDALONE_TERMINAL_TOKEN: terminalToken, STANDALONE_TERMINAL_ACTOR_ID: terminalActorId, }); // Второй процесс без токенов (как корневой compose.yaml), тот же tenant и БД: для F2/K-сценариев. const open = await startServer('no-tokens', { ...common, STANDALONE_ACCESS: 'manager', }); // Третий процесс с собственными токенами только для F4 (перебор входа): если rate limit // появится, он не должен блокировать вход в остальных сценариях с того же IP. const rateLimited = await startServer('rate-limit', { ...common, STANDALONE_ACCESS: 'employee', STANDALONE_MANAGER_TOKEN: randomBytes(32).toString('hex'), STANDALONE_TERMINAL_TOKEN: randomBytes(32).toString('hex'), }); const context: E2EContext = { baseUrl: secured.baseUrl, openBaseUrl: open.baseUrl, rateLimitBaseUrl: rateLimited.baseUrl, managerToken, terminalToken, tenantId, actorId, terminalActorId, appDatabaseUrl: appUrl, migrationDatabaseUrl: migrationUrl, adminDatabaseUrl, }; project.provide('e2e', context); return async () => { for (const server of [secured, open, rateLimited]) { server.child.kill('SIGTERM'); } await new Promise((resolve) => setTimeout(resolve, 500)); for (const server of [secured, open, rateLimited]) { if (server.child.exitCode === null) server.child.kill('SIGKILL'); } }; }