krona-staff/tests/e2e/10-core-defects.e2e.ts
grok 518494d03c test(e2e): сценарии дефектов основного API (N1-N4, N9-N12, F2-F4, F7, F10)
Зачем: каждый открытый дефект аудита должен иметь исполняемый сценарий
на реальной БД, который проверяет ПРАВИЛЬНОЕ поведение. Тогда красный
тест = открытый дефект, а исправление видно по смене цвета, без ручной
перепроверки.

- N1 POST /employees -> 201; N2 подтверждение смены, расчёт, утверждение
  и сумма начисления 8 ч x синтетическая ставка; N4 пересекающиеся
  периоды не начисляют смену дважды (сейчас блокирован N2 — это видно в
  сообщении assert как «предусловие, см. N2»);
- N3 SSRF: подписка на https://127.0.0.1:<порт> должна отклоняться, и
  локальный слушатель не должен получить соединение;
- N9, N12, N10, N11, F2 (процесс без токенов), F3, F4 (отдельный процесс),
  F7, F10.
Сотрудники для сценариев, не относящихся к N1, создаются импортом, чтобы
один дефект не маскировал остальные. Все данные синтетические (E2E-*),
ставка 123,45 ₽/ч заведомо не реальная.
2026-09-26 14:27:59 +07:00

326 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Сценарии дефектов основного API (ID из аудита Grok 2026-09-26; сопоставление с ID
// владельца — в REPORT.md). Каждый тест проверяет ПРАВИЛЬНОЕ поведение, поэтому
// красный тест = открытый дефект.
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
import { createECDH, randomBytes } from 'node:crypto';
import { createServer, type Server } from 'node:net';
import { describe, expect, it } from 'vitest';
import { api, ctx, dbQuery, describeResponse } from './harness/client.js';
import { randomUlid, synthetic } from './harness/context.js';
import {
SYNTHETIC_RATE_MINOR,
assignHourly,
clock,
createOrgUnits,
daysAgo,
hourlyRuleId,
importEmployees,
isoDate,
issueEmployeeToken,
workedShift,
} from './harness/fixtures.js';
function pushKeys() {
const ecdh = createECDH('prime256v1');
ecdh.generateKeys();
return {
p256dh: ecdh.getPublicKey().toString('base64url'),
auth: randomBytes(16).toString('base64url'),
};
}
function monthOf(date: Date): string {
return date.toISOString().slice(0, 7);
}
async function accrualsFor(employeeId: string) {
return dbQuery<{ amount_minor: string; ref_id: string }>(
`SELECT amount_minor::text AS amount_minor, ref_id FROM staff_payroll_ledger
WHERE employee_id = $1 AND kind = 'accrual'`,
[employeeId],
);
}
describe('Основной API на реальной PostgreSQL', () => {
it('[N1] менеджер создаёт сотрудника через POST /employees → 201', async () => {
const number = `E2E-${randomUlid().slice(-8)}`;
const response = await api('POST', '/employees', {
token: ctx().managerToken,
body: {
personnel_number: number,
display_name: synthetic('Сотрудник'),
position: 'E2E-должность',
},
});
expect(response.status, describeResponse(response)).toBe(201);
const rows = await dbQuery('SELECT id FROM staff_employees WHERE personnel_number = $1', [
number,
]);
expect(rows).toHaveLength(1);
});
it('[N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id);
const day = daysAgo(3);
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, {
token: ctx().managerToken,
body: { status: 'confirmed' },
});
expect(confirm.status, `подтверждение смены: ${describeResponse(confirm)}`).toBe(200);
const calculate = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { month: monthOf(day), org_unit_id: unit!.id },
});
expect(calculate.status, `расчёт: ${describeResponse(calculate)}`).toBe(200);
const runId = (calculate.body.run_id ?? calculate.body.id ?? calculate.body.run?.id) as string;
const approve = await api('POST', `/payroll/runs/${runId}/approve`, {
token: ctx().managerToken,
});
expect(approve.status, `утверждение: ${describeResponse(approve)}`).toBe(200);
const accruals = await accrualsFor(employee!.id);
const total = accruals.reduce((sum, row) => sum + BigInt(row.amount_minor), 0n);
expect(total).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR));
});
it('[N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id);
const day = daysAgo(2);
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, {
token: ctx().managerToken,
body: { status: 'confirmed' },
});
expect(
confirm.status,
`подтверждение смены (предусловие, см. N2): ${describeResponse(confirm)}`,
).toBe(200);
const month = monthOf(day);
const first = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { month, org_unit_id: unit!.id },
});
expect(first.status, `первый расчёт (предусловие, см. N2): ${describeResponse(first)}`).toBe(
200,
);
const firstRun = (first.body.run_id ?? first.body.id) as string;
expect(
(await api('POST', `/payroll/runs/${firstRun}/approve`, { token: ctx().managerToken }))
.status,
).toBe(200);
const lastDay = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth() + 1, 0));
const secondFrom = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), 2));
const second = await api('POST', '/payroll/calculate', {
token: ctx().managerToken,
body: { from: isoDate(secondFrom), to: isoDate(lastDay), org_unit_id: unit!.id },
});
if (second.status === 200) {
const secondRun = (second.body.run_id ?? second.body.id) as string;
await api('POST', `/payroll/runs/${secondRun}/approve`, { token: ctx().managerToken });
} else {
expect(second.status, describeResponse(second)).toBe(409);
}
const accruals = await accrualsFor(employee!.id);
const total = accruals.reduce((sum, row) => sum + BigInt(row.amount_minor), 0n);
expect(total, 'смена должна быть начислена ровно один раз').toBe(
BigInt(8 * SYNTHETIC_RATE_MINOR),
);
});
it('[N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => {
const [employee] = await importEmployees(1);
const employeeToken = await issueEmployeeToken(employee!.id);
let connections = 0;
const listener: Server = createServer((socket) => {
connections += 1;
socket.destroy();
});
await new Promise<void>((resolve) => listener.listen(0, '127.0.0.1', resolve));
const address = listener.address();
const port = typeof address === 'object' && address ? address.port : 0;
try {
const subscribe = await api('POST', '/push/subscriptions', {
token: employeeToken,
body: { endpoint: `https://127.0.0.1:${port}/e2e-internal-admin`, keys: pushKeys() },
});
// Инициируем отправку push этому сотруднику (отметка терминала) и ждём попытку соединения.
await clock(employee!.id, 'check_in', daysAgo(0, 0));
await new Promise((resolve) => setTimeout(resolve, 2_500));
expect(
{ status: subscribe.status, connections },
`подписка: ${describeResponse(subscribe)}`,
).toEqual({
status: expect.toSatisfy((s: number) => s === 400 || s === 422 || s === 403),
connections: 0,
});
} finally {
listener.close();
}
});
it('[N9] чужой push-endpoint нельзя перепривязать к другому сотруднику', async () => {
const [employeeA, employeeB] = await importEmployees(2);
const tokenA = await issueEmployeeToken(employeeA!.id);
const tokenB = await issueEmployeeToken(employeeB!.id);
const endpoint = `https://fcm.googleapis.com/fcm/send/e2e-${randomBytes(16).toString('hex')}`;
const first = await api('POST', '/push/subscriptions', {
token: tokenA,
body: { endpoint, keys: pushKeys() },
});
expect(first.status, describeResponse(first)).toBe(201);
const second = await api('POST', '/push/subscriptions', {
token: tokenB,
body: { endpoint, keys: pushKeys() },
});
const owner = await dbQuery<{ employee_id: string }>(
'SELECT employee_id FROM staff_push_subscriptions WHERE endpoint = $1',
[endpoint],
);
expect(
{ status: second.status, owner: owner.map((row) => row.employee_id) },
describeResponse(second),
).toEqual({
status: expect.toSatisfy((s: number) => s === 409 || s === 403),
owner: [employeeA!.id],
});
});
it('[N12] PUT pay-assignment отвергает лишние поля и нереальные суммы (400)', async () => {
const [employee] = await importEmployees(1);
const payRuleId = await hourlyRuleId();
const extra = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
token: ctx().managerToken,
body: { pay_rule_id: payRuleId, rate_minor: SYNTHETIC_RATE_MINOR, e2e_unexpected_field: 'x' },
});
const huge = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
token: ctx().managerToken,
body: {
pay_rule_id: payRuleId,
rate_minor: Number.MAX_SAFE_INTEGER,
guarantee_minor: Number.MAX_SAFE_INTEGER,
},
});
expect(
{ extra: extra.status, huge: huge.status },
`${describeResponse(extra)} | ${describeResponse(huge)}`,
).toEqual({
extra: 400,
huge: 400,
});
});
it('[N10] терминальный токен не читает справочники профессий и точек (403)', async () => {
const professions = await api('GET', '/professions', { token: ctx().terminalToken });
const units = await api('GET', '/org-units', { token: ctx().terminalToken });
expect({ professions: professions.status, units: units.status }).toEqual({
professions: 403,
units: 403,
});
});
it('[F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401)', async () => {
const portal = await api('GET', '/portal', { base: 'open' });
const payroll = await api('GET', '/payroll', { base: 'open' });
const write = await api('POST', '/professions', {
base: 'open',
body: { name: synthetic('Профессия') },
});
expect({ portal: portal.status, payroll: payroll.status, write: write.status }).toEqual({
portal: 401,
payroll: 401,
write: 401,
});
});
it('[F3] cookie сессии не содержит сырой токен и отзывается при выходе', async () => {
const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } });
expect(login.status, describeResponse(login)).toBe(200);
const setCookie = login.headers.get('set-cookie') ?? '';
const value = decodeURIComponent(
/__Host-krona_staff_session=([^;]*)/.exec(setCookie)?.[1] ?? '',
);
const cookie = `__Host-krona_staff_session=${encodeURIComponent(value)}`;
const logout = await api('DELETE', '/session', { headers: { cookie } });
expect(logout.status).toBe(204);
const reuse = await api('GET', '/portal', { headers: { cookie } });
expect({ rawTokenInCookie: value === ctx().managerToken, afterLogout: reuse.status }).toEqual({
rawTokenInCookie: false,
afterLogout: 401,
});
});
it('[F10] мутация по cookie без Origin отклоняется (CSRF, 403)', async () => {
const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } });
const setCookie = login.headers.get('set-cookie') ?? '';
const cookie = /(__Host-krona_staff_session=[^;]*)/.exec(setCookie)?.[1] ?? '';
const response = await api('POST', '/professions', {
headers: { cookie },
body: { name: synthetic('CSRF-профессия') },
});
expect(response.status, describeResponse(response)).toBe(403);
});
it('[F7] повтор Idempotency-Key с другим телом не создаёт второе событие', async () => {
const [employee] = await importEmployees(1);
const key = `e2e-${randomUlid()}`;
const send = (sourceEventId: string) =>
api('POST', '/attendance/events', {
token: ctx().terminalToken,
headers: { 'idempotency-key': key },
body: {
employee_id: employee!.id,
event_type: 'check_in',
occurred_at: daysAgo(1).toISOString(),
source_event_id: sourceEventId,
},
});
const first = await send(`e2e-${randomUlid()}`);
const second = await send(`e2e-${randomUlid()}`);
expect({ first: first.status, secondCreated: second.status === 201 }).toEqual({
first: 201,
secondCreated: false,
});
});
it('[N11] импорт с неверным kind начисления → 400, а не 500', async () => {
const [employee] = await importEmployees(1);
const response = await api('POST', '/imports/owner-workbook', {
token: ctx().managerToken,
body: {
source_name: synthetic('import-bad-kind'),
payroll_entries: [
{
external_key: `e2e-${randomUlid()}`,
employee_external_ref: employee!.external_ref,
business_date: isoDate(daysAgo(1)),
kind: 'e2e_bogus_kind',
amount_minor: 100,
},
],
},
});
expect(response.status, describeResponse(response)).toBe(400);
});
it('[F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => {
const statuses: number[] = [];
for (let attempt = 0; attempt < 30; attempt += 1) {
const response = await api('POST', '/session', {
base: 'ratelimit',
body: { access_token: randomBytes(32).toString('hex') },
});
statuses.push(response.status);
if (response.status === 429) break;
}
expect(statuses.includes(429), `статусы: ${[...new Set(statuses)].join(',')}`).toBe(true);
});
});