test(e2e): стенд с реальным сервером и PostgreSQL 17

Зачем: 156 зелёных тестов на fakeDatabase не поймали, что ключевые
сценарии (создание сотрудника, подтверждение смены, расчёт зарплаты)
всегда падают на реальной PostgreSQL. Нужен приёмочный стенд, который
проверяет продукт как чёрный ящик: настоящий процесс сервера, настоящая
БД, роли как в pilot, но с минимальными привилегиями.

- tests/e2e/harness/global-setup.ts: создаёт БД и роли через
  scripts/test/provision-pg.mjs, применяет миграции ролью krona_migrator
  (NOSUPERUSER), поднимает `tsx src/server.ts` под krona_staff_app с
  токенами/VAPID из crypto.randomBytes; три процесса: с токенами, без
  токенов (как корневой compose.yaml) и отдельный для проверки rate limit;
  логи серверов пишутся в tests/e2e/evidence/.
- *.e2e.ts и отдельный vitest.e2e.config.ts: e2e не попадают в `npm test`,
  поэтому `npm run check` не требует БД и не краснеет от известных дефектов.
- [HARNESS]/[CTRL] — позитивные контроли (версия PG >= 17, атрибуты ролей,
  app-роль не видит schema_migrations, RBAC, уволенный теряет доступ):
  если они красные, красные дефектные сценарии ничего не доказывают.
- scripts/test/run-e2e.mjs: «храповик» известных дефектов по
  tests/e2e/known-defects.json (новый красный или исправленный-но-не-
  вычеркнутый ID = exit 1), таблица e2e-table.md и проверка, что в
  evidence нет ничего похожего на секреты стенда.

Логи названы *.txt, т.к. *.log в .gitignore.
This commit is contained in:
grok 2026-09-26 14:27:59 +07:00
parent d7e9008b8c
commit cf7f6f4510
8 changed files with 735 additions and 0 deletions

122
scripts/test/run-e2e.mjs Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env node
// Запуск e2e с «храповиком» известных дефектов.
// * Сценарии проверяют ПРАВИЛЬНОЕ поведение, поэтому открытые дефекты дают красные тесты.
// * tests/e2e/known-defects.json — список ID, красный цвет которых сейчас ожидаем.
// * exit 0, если: все [HARNESS]-тесты зелёные И каждый красный тест есть в списке
// И ни один ID из списка не стал полностью зелёным (исправленный дефект нужно
// убрать из списка — иначе регрессию потом никто не заметит).
// * --no-known-defects: строгий режим, exit = код vitest (все дефекты красные).
// Полный вывод пишется в tests/e2e/evidence/e2e-run.txt, таблица — в e2e-table.md.
import { spawn } from 'node:child_process';
import { createWriteStream, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const evidence = path.join(repoRoot, 'tests/e2e/evidence');
mkdirSync(evidence, { recursive: true });
const strict = process.argv.includes('--no-known-defects');
const jsonPath = path.join(evidence, 'e2e-results.json');
const logPath = path.join(evidence, 'e2e-run.txt');
const extra = process.argv.slice(2).filter((arg) => !arg.startsWith('--no-known-defects'));
const vitestArgs = [
'vitest',
'run',
'--config',
'tests/e2e/vitest.e2e.config.ts',
'--reporter=verbose',
'--reporter=json',
`--outputFile.json=${jsonPath}`,
...extra,
];
const log = createWriteStream(logPath);
log.write(`$ npx ${vitestArgs.join(' ')}\n`);
const exitCode = await new Promise((resolve) => {
const child = spawn('npx', vitestArgs, { cwd: repoRoot, env: process.env });
for (const stream of [child.stdout, child.stderr]) {
stream.on('data', (chunk) => {
process.stdout.write(chunk);
log.write(chunk);
});
}
child.on('close', (code) => resolve(code ?? 1));
});
log.end();
let results;
try {
results = JSON.parse(readFileSync(jsonPath, 'utf8'));
} catch {
console.error('[run-e2e] нет JSON-результатов: стенд не поднялся (см. e2e-run.txt)');
process.exit(1);
}
const known = strict
? {}
: JSON.parse(readFileSync(path.join(repoRoot, 'tests/e2e/known-defects.json'), 'utf8')).defects;
const byId = new Map();
for (const file of results.testResults) {
for (const test of file.assertionResults) {
const id = /^\[([A-Z0-9-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED';
const entry = byId.get(id) ?? { passed: 0, failed: 0, tests: [] };
if (test.status === 'passed') entry.passed += 1;
else if (test.status === 'failed') entry.failed += 1;
entry.tests.push({ title: test.title, status: test.status });
byId.set(id, entry);
}
}
const problems = [];
// Страховка: в evidence не должно попасть ничего похожего на секреты стенда
// (hex-токены 48/64 символа, токены устройств kstaff_dev_*, URL с паролем).
const secretPatterns = [
/\b[0-9a-f]{48}\b/,
/\b[0-9a-f]{64}\b/,
/kstaff_dev_[0-9A-Z]{26}_[0-9a-f]{16,}/,
/postgres:\/\/[^\s:@]+:[^\s@]+@/,
];
for (const name of readdirSync(evidence)) {
const content = readFileSync(path.join(evidence, name), 'utf8');
for (const pattern of secretPatterns) {
if (pattern.test(content))
problems.push(`evidence/${name}: найдено похожее на секрет (${pattern})`);
}
}
const rows = [];
for (const [id, entry] of [...byId.entries()].sort()) {
const red = entry.failed > 0;
let verdict;
if (id === 'HARNESS' || id === 'UNTAGGED') {
verdict = red ? 'СТЕНД СЛОМАН' : 'зелёный (позитивный контроль)';
if (red || id === 'UNTAGGED') problems.push(`${id}: ${red ? 'красный' : 'тест без ID'}`);
} else if (red && known[id]) verdict = 'красный (известный дефект)';
else if (red) {
verdict = 'красный (НЕ в known-defects)';
problems.push(`${id}: новый красный тест — регрессия или новый дефект`);
} else if (known[id]) {
verdict = 'зелёный — ИСПРАВЛЕН, уберите из known-defects.json';
problems.push(`${id}: дефект исправлен, обновите known-defects.json`);
} else verdict = 'зелёный';
rows.push(
`| ${id} | ${red ? 'RED' : 'GREEN'} | ${entry.passed}/${entry.passed + entry.failed} | ${verdict} |`,
);
}
for (const id of Object.keys(known)) {
if (!byId.has(id)) problems.push(`${id}: есть в known-defects.json, но сценария нет`);
}
const table = ['| ID | Цвет | Зелёных/всего | Вердикт |', '|---|---|---|---|', ...rows].join('\n');
writeFileSync(path.join(evidence, 'e2e-table.md'), `${table}\n`);
console.log(`\n[run-e2e] vitest exit=${exitCode}\n${table}`);
if (strict) {
if (problems.some((p) => p.startsWith('evidence/'))) {
console.error(`\n[run-e2e] FAIL:\n- ${problems.join('\n- ')}`);
process.exit(1);
}
process.exit(exitCode);
}
if (problems.length > 0) {
console.error(`\n[run-e2e] FAIL:\n- ${problems.join('\n- ')}`);
process.exit(1);
}
console.log('\n[run-e2e] OK: стенд работает, все красные сценарии — известные открытые дефекты');

119
tests/e2e/00-harness.e2e.ts Normal file
View file

@ -0,0 +1,119 @@
// [HARNESS] Позитивный контроль стенда: эти тесты обязаны быть зелёными всегда.
// Если они красные — сломан сам стенд, и красные дефектные сценарии ничего не доказывают.
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import pg from 'pg';
import { describe, expect, it } from 'vitest';
import { api, ctx, describeResponse } from './harness/client.js';
describe('HARNESS: реальный сервер + PostgreSQL 17', () => {
it('[HARNESS] сервер отвечает live/ready (ready проверяет БД)', async () => {
const live = await api('GET', '/live');
expect(live.status).toBe(200);
const ready = await api('GET', '/ready');
expect(ready.status, describeResponse(ready)).toBe(200);
expect(ready.body).toEqual({ status: 'ready' });
});
it('[HARNESS] БД: PostgreSQL 17, приложение под ролью без SUPERUSER/BYPASSRLS, мигратор без SUPERUSER', async () => {
const client = new pg.Client({ connectionString: ctx().appDatabaseUrl });
await client.connect();
try {
const version = await client.query<{ server_version_num: string }>('SHOW server_version_num');
expect(Number(version.rows[0]!.server_version_num)).toBeGreaterThanOrEqual(170000);
const roles = await client.query<{
rolname: string;
rolsuper: boolean;
rolbypassrls: boolean;
rolcreaterole: boolean;
}>(
`SELECT rolname, rolsuper, rolbypassrls, rolcreaterole FROM pg_roles
WHERE rolname IN (current_user, 'krona_migrator') ORDER BY rolname`,
);
expect(roles.rows).toEqual([
{ rolname: 'krona_migrator', rolsuper: false, rolbypassrls: false, rolcreaterole: false },
{ rolname: 'krona_staff_app', rolsuper: false, rolbypassrls: false, rolcreaterole: false },
]);
} finally {
await client.end();
}
});
it('[HARNESS] миграции применены мигратором, app-роль не видит schema_migrations', async () => {
const migrator = new pg.Client({ connectionString: ctx().migrationDatabaseUrl });
await migrator.connect();
try {
const applied = await migrator.query<{ count: string }>(
'SELECT count(*)::text AS count FROM schema_migrations',
);
expect(Number(applied.rows[0]!.count)).toBeGreaterThanOrEqual(11);
} finally {
await migrator.end();
}
const app = new pg.Client({ connectionString: ctx().appDatabaseUrl });
await app.connect();
try {
await expect(app.query('SELECT 1 FROM schema_migrations')).rejects.toThrow(
/permission denied/,
);
} finally {
await app.end();
}
});
it('[HARNESS] токены: без токена 401, случайный токен 401, manager-токен 200', async () => {
expect((await api('GET', '/portal')).status).toBe(401);
expect((await api('GET', '/portal', { token: 'x'.repeat(64) })).status).toBe(401);
const ok = await api('GET', '/portal', { token: ctx().managerToken });
expect(ok.status, describeResponse(ok)).toBe(200);
expect(ok.body.viewer.role).toBe('manager');
});
it('[HARNESS] терминал: отметка сотрудника из импорта принимается (201)', async () => {
const { importEmployees, clock, daysAgo } = await import('./harness/fixtures.js');
const [employee] = await importEmployees(1);
const response = await clock(employee!.id, 'check_in', daysAgo(1));
expect(response.status, describeResponse(response)).toBe(201);
});
// [CTRL] Позитивные контроли безопасности, которые аудит признал рабочими: они
// должны оставаться зелёными и доказывают, что сценарии умеют быть зелёными.
it('[CTRL] RBAC: терминал не видит портал и не создаёт сотрудников; сотрудник видит только себя', async () => {
const { importEmployees, issueEmployeeToken } = await import('./harness/fixtures.js');
const [self, other] = await importEmployees(2);
const employeeToken = await issueEmployeeToken(self!.id);
const terminalPortal = await api('GET', '/portal', { token: ctx().terminalToken });
const terminalCreate = await api('POST', '/employees', {
token: ctx().terminalToken,
body: { personnel_number: 'E2E-CTRL', display_name: 'E2E-CTRL' },
});
const own = await api('GET', '/portal', { token: employeeToken });
const rows = (own.body?.employees ?? []) as Array<{ id: string }>;
const ids = rows.map((row) => row.id);
expect({
terminalPortal: terminalPortal.status,
terminalCreate: terminalCreate.status,
own: own.status,
seesSelf: ids.includes(self!.id),
seesOther: ids.includes(other!.id),
}).toEqual({
terminalPortal: 403,
terminalCreate: 403,
own: 200,
seesSelf: true,
seesOther: false,
});
});
it('[CTRL] уволенный сотрудник теряет доступ (401)', async () => {
const { importEmployees, issueEmployeeToken } = await import('./harness/fixtures.js');
const [employee] = await importEmployees(1);
const employeeToken = await issueEmployeeToken(employee!.id);
expect((await api('GET', '/portal', { token: employeeToken })).status).toBe(200);
const dismiss = await api('PATCH', `/employees/${employee!.id}`, {
token: ctx().managerToken,
body: { status: 'dismissed' },
});
expect(dismiss.status, describeResponse(dismiss)).toBe(200);
expect((await api('GET', '/portal', { token: employeeToken })).status).toBe(401);
});
});

View file

@ -0,0 +1,86 @@
// HTTP-клиент e2e. Тело ответа типизировано как `any` намеренно: тесты проверяют
// внешний контракт JSON, а не типы src/. Правила no-unsafe-* отключены только здесь
// и в файлах сценариев.
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
import pg from 'pg';
import { inject } from 'vitest';
import type { E2EContext } from './context.js';
export interface ApiResponse {
status: number;
body: any;
text: string;
headers: Headers;
}
export interface RequestOptions {
token?: string | undefined;
body?: unknown;
headers?: Record<string, string>;
base?: 'secured' | 'open' | 'ratelimit';
}
export function ctx(): E2EContext {
return inject('e2e');
}
export async function api(
method: string,
path: string,
options: RequestOptions = {},
): Promise<ApiResponse> {
const context = ctx();
const base =
options.base === 'open'
? context.openBaseUrl
: options.base === 'ratelimit'
? context.rateLimitBaseUrl
: context.baseUrl;
const headers: Record<string, string> = { ...(options.headers ?? {}) };
if (options.token) headers.authorization = `Bearer ${options.token}`;
let payload: string | undefined;
if (options.body !== undefined) {
headers['content-type'] = 'application/json';
payload = JSON.stringify(options.body);
}
const url = path.startsWith('/api/') || path.startsWith('/staff') ? path : `/api/v1/staff${path}`;
const response = await fetch(`${base}${url}`, {
method,
headers,
...(payload === undefined ? {} : { body: payload }),
redirect: 'manual',
});
const text = await response.text();
let body: any = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = null;
}
return { status: response.status, body, text, headers: response.headers };
}
/** Краткое описание ответа для сообщений assert (без токенов). */
export function describeResponse(response: ApiResponse): string {
const snippet = response.text.length > 300 ? `${response.text.slice(0, 300)}…` : response.text;
return `HTTP ${response.status} ${snippet}`;
}
/** Запрос к БД ролью приложения внутри tenant-транзакции (RLS действует). */
export async function dbQuery<T extends pg.QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
const context = ctx();
const client = new pg.Client({ connectionString: context.appDatabaseUrl });
await client.connect();
try {
await client.query('BEGIN');
await client.query("SELECT set_config('app.tenant_id', $1, true)", [context.tenantId]);
const result = await client.query<T>(sql, params);
await client.query('COMMIT');
return result.rows;
} finally {
await client.end();
}
}

View file

@ -0,0 +1,41 @@
import { randomBytes } from 'node:crypto';
export interface E2EContext {
baseUrl: string;
openBaseUrl: string;
rateLimitBaseUrl: string;
managerToken: string;
terminalToken: string;
tenantId: string;
actorId: string;
terminalActorId: string;
appDatabaseUrl: string;
migrationDatabaseUrl: string;
}
declare module 'vitest' {
export interface ProvidedContext {
e2e: E2EContext;
}
}
const crockford = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
/** Случайный ULID (время + crypto.randomBytes), чтобы не зависеть от src/. */
export function randomUlid(): string {
let time = Date.now();
let timePart = '';
for (let i = 0; i < 10; i += 1) {
timePart = crockford[time % 32]! + timePart;
time = Math.floor(time / 32);
}
const bytes = randomBytes(16);
let randomPart = '';
for (let i = 0; i < 16; i += 1) randomPart += crockford[bytes[i]! % 32]!;
return timePart + randomPart;
}
/** Синтетическая метка для данных тестов: очевидно не реальные люди/суммы. */
export function synthetic(label: string): string {
return `E2E-${label}-${randomBytes(3).toString('hex')}`;
}

View file

@ -0,0 +1,155 @@
// Подготовка данных через публичный API. Везде только синтетические данные.
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { expect } from 'vitest';
import { api, ctx, dbQuery, describeResponse } from './client.js';
import { randomUlid, synthetic } from './context.js';
export interface OrgUnit {
id: string;
code: string;
}
export interface Employee {
id: string;
external_ref: string;
personnel_number: string;
}
/** Создаёт торговые точки через импорт (единственный публичный способ в текущем API). */
export async function createOrgUnits(count: number): Promise<OrgUnit[]> {
const units = Array.from({ length: count }, () => {
const code = `e2e-${randomUlid().slice(-10).toLowerCase()}`;
return { code, name: synthetic('Точка') };
});
const response = await api('POST', '/imports/owner-workbook', {
token: ctx().managerToken,
body: { source_name: synthetic('import-units'), units },
});
expect(response.status, describeResponse(response)).toBeLessThan(300);
const rows = await dbQuery<{ id: string; code: string }>(
'SELECT id, code FROM staff_org_units WHERE code = ANY($1::text[])',
[units.map((unit) => unit.code)],
);
return units.map((unit) => ({
code: unit.code,
id: rows.find((row) => row.code === unit.code)!.id,
}));
}
/**
* Создаёт сотрудников через импорт книги владельца: POST /employees на реальной БД
* сейчас падает (N1), а сценарии, не относящиеся к N1, не должны зависеть от этого дефекта.
*/
export async function importEmployees(count: number): Promise<Employee[]> {
const employees = Array.from({ length: count }, () => {
const suffix = randomUlid().slice(-8);
return {
external_ref: `e2e:${suffix}`,
personnel_number: `E2E-${suffix}`,
display_name: synthetic('Сотрудник'),
};
});
const response = await api('POST', '/imports/owner-workbook', {
token: ctx().managerToken,
body: { source_name: synthetic('import-employees'), employees },
});
expect(response.status, describeResponse(response)).toBeLessThan(300);
const rows = await dbQuery<{ id: string; external_ref: string }>(
'SELECT id, external_ref FROM staff_employees WHERE external_ref = ANY($1::text[])',
[employees.map((employee) => employee.external_ref)],
);
return employees.map((employee) => ({
...employee,
id: rows.find((row) => row.external_ref === employee.external_ref)!.id,
}));
}
export async function issueEmployeeToken(employeeId: string): Promise<string> {
const response = await api('POST', `/employees/${employeeId}/access-token`, {
token: ctx().managerToken,
});
expect(response.status, describeResponse(response)).toBe(200);
return response.body.access_token as string;
}
export async function clock(
employeeId: string,
eventType: 'check_in' | 'check_out',
occurredAt: Date,
orgUnitId?: string,
) {
return api('POST', '/attendance/events', {
token: ctx().terminalToken,
body: {
employee_id: employeeId,
event_type: eventType,
occurred_at: occurredAt.toISOString(),
source_event_id: `e2e-${randomUlid()}`,
...(orgUnitId ? { org_unit_id: orgUnitId } : {}),
},
});
}
/** Дата (UTC) N дней назад в полдень: всегда в окне терминала (<=180 дней) и не в будущем. */
export function daysAgo(days: number, hourUtc = 9): Date {
const date = new Date();
date.setUTCDate(date.getUTCDate() - days);
date.setUTCHours(hourUtc, 0, 0, 0);
return date;
}
export function isoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** Отработанная смена: check_in/check_out терминалом + сверка; возвращает id смены. */
export async function workedShift(
employeeId: string,
orgUnitId: string,
day: Date,
hours = 8,
): Promise<string> {
const start = new Date(day);
const end = new Date(start.getTime() + hours * 3_600_000);
const inResponse = await clock(employeeId, 'check_in', start, orgUnitId);
expect(inResponse.status, describeResponse(inResponse)).toBe(201);
const outResponse = await clock(employeeId, 'check_out', end, orgUnitId);
expect(outResponse.status, describeResponse(outResponse)).toBe(201);
const reconcile = await api('POST', '/work-sessions/reconcile', {
token: ctx().managerToken,
body: { date_from: isoDate(start), date_to: isoDate(start) },
});
expect(reconcile.status, describeResponse(reconcile)).toBe(200);
const rows = await dbQuery<{ id: string }>(
`SELECT id FROM staff_work_sessions
WHERE employee_id = $1 AND org_unit_id = $2 AND business_date = $3::date`,
[employeeId, orgUnitId, isoDate(start)],
);
expect(rows.length, 'сверка должна создать смену').toBe(1);
return rows[0]!.id;
}
export async function hourlyRuleId(): Promise<string> {
const response = await api('GET', '/pay-rules', { token: ctx().managerToken });
expect(response.status, describeResponse(response)).toBe(200);
const rules = response.body.pay_rules as Array<{ id: string; code: string }>;
const hourly = rules.find((rule) => rule.code === 'hourly') ?? rules[0];
expect(hourly, 'должно существовать хотя бы одно правило оплаты').toBeTruthy();
return hourly!.id;
}
/** Синтетическая ставка 123,45 ₽/ч (заведомо не реальная сумма). */
export const SYNTHETIC_RATE_MINOR = 12_345;
export async function assignHourly(employeeId: string, orgUnitId: string | null) {
const response = await api('PUT', `/employees/${employeeId}/pay-assignment`, {
token: ctx().managerToken,
body: {
pay_rule_id: await hourlyRuleId(),
org_unit_id: orgUnitId,
rate_minor: SYNTHETIC_RATE_MINOR,
effective_from: '2026-01-01',
},
});
expect(response.status, describeResponse(response)).toBe(200);
}

View file

@ -0,0 +1,190 @@
// Глобальная подготовка 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 { 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 tsx = path.join(repoRoot, 'node_modules/.bin/tsx');
const child = spawn(tsx, ['src/server.ts'], {
cwd: repoRoot,
env: {
PATH: process.env.PATH ?? '',
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}`);
}
export default async function setup(project: TestProject) {
mkdirSync(evidenceDir, { recursive: true });
const adminUrl = process.env.E2E_PG_ADMIN_URL;
if (!adminUrl) {
throw new Error(
'E2E_PG_ADMIN_URL не задан: нужен bootstrap-админ PostgreSQL 17 (только для создания ролей и БД)',
);
}
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 });
const migrationUrl = dbEnv.DATABASE_MIGRATION_URL;
const appUrl = dbEnv.DATABASE_URL;
if (!migrationUrl || !appUrl) throw new Error('provision did not produce connection URLs');
const migrationMode = process.env.E2E_MIGRATIONS ?? 'up';
runNode(
['scripts/test/migrate-roundtrip.mjs', migrationMode === 'roundtrip' ? 'roundtrip' : 'up'],
{ PATH: process.env.PATH ?? '', 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,
};
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');
}
};
}

View file

@ -0,0 +1,4 @@
{
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md.",
"defects": {}
}

View file

@ -0,0 +1,18 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
// Отдельный конфиг: e2e-файлы названы *.e2e.ts и не попадают в `npm test`
// (корневой vitest ищет только *.test.ts), поэтому `npm run check` не зависит от
// живой PostgreSQL и от известных открытых дефектов.
const root = fileURLToPath(new URL('../..', import.meta.url));
export default defineConfig({
root,
test: {
include: ['tests/e2e/**/*.e2e.ts'],
globalSetup: ['tests/e2e/harness/global-setup.ts'],
fileParallelism: false,
testTimeout: 60_000,
hookTimeout: 180_000,
},
});