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:
parent
4f3e5c621a
commit
646e8d4f22
8 changed files with 735 additions and 0 deletions
122
scripts/test/run-e2e.mjs
Normal file
122
scripts/test/run-e2e.mjs
Normal 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
119
tests/e2e/00-harness.e2e.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
tests/e2e/harness/client.ts
Normal file
86
tests/e2e/harness/client.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
tests/e2e/harness/context.ts
Normal file
41
tests/e2e/harness/context.ts
Normal 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')}`;
|
||||||
|
}
|
||||||
155
tests/e2e/harness/fixtures.ts
Normal file
155
tests/e2e/harness/fixtures.ts
Normal 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);
|
||||||
|
}
|
||||||
190
tests/e2e/harness/global-setup.ts
Normal file
190
tests/e2e/harness/global-setup.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
4
tests/e2e/known-defects.json
Normal file
4
tests/e2e/known-defects.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md.",
|
||||||
|
"defects": {}
|
||||||
|
}
|
||||||
18
tests/e2e/vitest.e2e.config.ts
Normal file
18
tests/e2e/vitest.e2e.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue