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:
parent
cf7f6f4510
commit
518494d03c
2 changed files with 341 additions and 1 deletions
326
tests/e2e/10-core-defects.e2e.ts
Normal file
326
tests/e2e/10-core-defects.e2e.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,18 @@
|
||||||
{
|
{
|
||||||
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md.",
|
"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 разрешены"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue