Почему: TASK-GROK-3 п.1 — на общем кластере (параллельные прогоны приёмки, чужой стенд) пересоздание ролей krona_migrator/krona_staff_app со случайными паролями ломает соседей. Если заданы оба URL, global-setup не вызывает provision и не требует E2E_PG_ADMIN_URL; по умолчанию поведение прежнее. provision-pg получил --roles keep: пересоздать только БД, не меняя паролей (нужно accept-branch для отдельной БД на ветку). ctx.adminDatabaseUrl (E2E_DB_ADMIN_URL или E2E_PG_ADMIN_URL + БД стенда) — только для PILOT-REHOME. Проверено: run-e2e с E2E_MIGRATOR_URL/E2E_APP_URL без E2E_PG_ADMIN_URL — 45 failed | 9 passed, как на основе; повторный прогон на уже мигрированной БД — то же.
236 lines
9 KiB
TypeScript
236 lines
9 KiB
TypeScript
// Глобальная подготовка 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<string, string> {
|
||
const result: Record<string, string> = {};
|
||
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<number> {
|
||
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<string, string> {
|
||
const output = spawnSync(process.execPath, ['scripts/generate-vapid-keys.mjs'], {
|
||
cwd: repoRoot,
|
||
encoding: 'utf8',
|
||
}).stdout;
|
||
const env: Record<string, string> = {};
|
||
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<string, string>): Promise<StartedServer> {
|
||
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');
|
||
}
|
||
};
|
||
}
|