krona-staff/tests/e2e/00-harness.e2e.ts
grok 646e8d4f22 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.
2026-09-26 15:18:31 +07:00

119 lines
5.7 KiB
TypeScript

// [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);
});
});