krona-staff/tests/e2e/harness/global-setup.ts
grok aa7f5f8bbd test(e2e): готовые роли через E2E_MIGRATOR_URL/E2E_APP_URL
Почему: 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,
как на основе; повторный прогон на уже мигрированной БД — то же.
2026-09-26 16:01:19 +07:00

236 lines
9 KiB
TypeScript
Raw Permalink 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.

// Глобальная подготовка 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');
}
};
}