Зачем: 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.
86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
// 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();
|
||
}
|
||
}
|