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 ₽/ч заведомо не реальная.
This commit is contained in:
grok 2026-09-26 14:27:59 +07:00
parent 646e8d4f22
commit 397954be0a
2 changed files with 341 additions and 1 deletions

View file

@ -0,0 +1,326 @@
// Сценарии дефектов основного 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);
});
});

View file

@ -1,4 +1,18 @@
{
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md.",
"defects": {}
"defects": {
"N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
"N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
"N3": "SSRF через push-endpoint на внутренние адреса",
"N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
"N9": "перепривязка чужого push-endpoint",
"N10": "справочники доступны терминалу",
"N11": "импорт с неверным kind → 500",
"N12": "pay-assignment без additionalProperties:false и верхних границ",
"F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму",
"F3": "cookie = сырой токен, logout не отзывает",
"F4": "нет rate limit на вход",
"F7": "Idempotency-Key не используется",
"F10": "CSRF: мутации по cookie без Origin разрешены"
}
}