From cf7f6f45104af003fa8620aa4de8f3ebeb0ef6b0 Mon Sep 17 00:00:00 2001 From: grok Date: Sat, 26 Sep 2026 14:27:59 +0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=D1=81=D1=82=D0=B5=D0=BD=D0=B4=20?= =?UTF-8?q?=D1=81=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=BC=20=D0=B8=20Pos?= =?UTF-8?q?tgreSQL=2017?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: 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. --- scripts/test/run-e2e.mjs | 122 +++++++++++++++++++ tests/e2e/00-harness.e2e.ts | 119 +++++++++++++++++++ tests/e2e/harness/client.ts | 86 ++++++++++++++ tests/e2e/harness/context.ts | 41 +++++++ tests/e2e/harness/fixtures.ts | 155 ++++++++++++++++++++++++ tests/e2e/harness/global-setup.ts | 190 ++++++++++++++++++++++++++++++ tests/e2e/known-defects.json | 4 + tests/e2e/vitest.e2e.config.ts | 18 +++ 8 files changed, 735 insertions(+) create mode 100644 scripts/test/run-e2e.mjs create mode 100644 tests/e2e/00-harness.e2e.ts create mode 100644 tests/e2e/harness/client.ts create mode 100644 tests/e2e/harness/context.ts create mode 100644 tests/e2e/harness/fixtures.ts create mode 100644 tests/e2e/harness/global-setup.ts create mode 100644 tests/e2e/known-defects.json create mode 100644 tests/e2e/vitest.e2e.config.ts diff --git a/scripts/test/run-e2e.mjs b/scripts/test/run-e2e.mjs new file mode 100644 index 0000000..bc693ed --- /dev/null +++ b/scripts/test/run-e2e.mjs @@ -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: стенд работает, все красные сценарии — известные открытые дефекты'); diff --git a/tests/e2e/00-harness.e2e.ts b/tests/e2e/00-harness.e2e.ts new file mode 100644 index 0000000..b4fd772 --- /dev/null +++ b/tests/e2e/00-harness.e2e.ts @@ -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); + }); +}); diff --git a/tests/e2e/harness/client.ts b/tests/e2e/harness/client.ts new file mode 100644 index 0000000..78be9ba --- /dev/null +++ b/tests/e2e/harness/client.ts @@ -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; + base?: 'secured' | 'open' | 'ratelimit'; +} + +export function ctx(): E2EContext { + return inject('e2e'); +} + +export async function api( + method: string, + path: string, + options: RequestOptions = {}, +): Promise { + const context = ctx(); + const base = + options.base === 'open' + ? context.openBaseUrl + : options.base === 'ratelimit' + ? context.rateLimitBaseUrl + : context.baseUrl; + const headers: Record = { ...(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>( + sql: string, + params: unknown[] = [], +): Promise { + 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(sql, params); + await client.query('COMMIT'); + return result.rows; + } finally { + await client.end(); + } +} diff --git a/tests/e2e/harness/context.ts b/tests/e2e/harness/context.ts new file mode 100644 index 0000000..8e9dfcc --- /dev/null +++ b/tests/e2e/harness/context.ts @@ -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')}`; +} diff --git a/tests/e2e/harness/fixtures.ts b/tests/e2e/harness/fixtures.ts new file mode 100644 index 0000000..cafcd4f --- /dev/null +++ b/tests/e2e/harness/fixtures.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/tests/e2e/harness/global-setup.ts b/tests/e2e/harness/global-setup.ts new file mode 100644 index 0000000..5258209 --- /dev/null +++ b/tests/e2e/harness/global-setup.ts @@ -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 { + 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 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'); + } + }; +} diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json new file mode 100644 index 0000000..dad4691 --- /dev/null +++ b/tests/e2e/known-defects.json @@ -0,0 +1,4 @@ +{ + "comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md.", + "defects": {} +} diff --git a/tests/e2e/vitest.e2e.config.ts b/tests/e2e/vitest.e2e.config.ts new file mode 100644 index 0000000..e2c1648 --- /dev/null +++ b/tests/e2e/vitest.e2e.config.ts @@ -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, + }, +});