From 397954be0aa2e00fab816d79a82a66c229312356 Mon Sep 17 00:00:00 2001 From: grok Date: Sat, 26 Sep 2026 14:27:59 +0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=B4=D0=B5=D1=84=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20API=20(N1-N4,=20N9-N12,=20F2-F4,=20F7,=20F10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: каждый открытый дефект аудита должен иметь исполняемый сценарий на реальной БД, который проверяет ПРАВИЛЬНОЕ поведение. Тогда красный тест = открытый дефект, а исправление видно по смене цвета, без ручной перепроверки. - 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 ₽/ч заведомо не реальная. --- tests/e2e/10-core-defects.e2e.ts | 326 +++++++++++++++++++++++++++++++ tests/e2e/known-defects.json | 16 +- 2 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/10-core-defects.e2e.ts diff --git a/tests/e2e/10-core-defects.e2e.ts b/tests/e2e/10-core-defects.e2e.ts new file mode 100644 index 0000000..d2d8d74 --- /dev/null +++ b/tests/e2e/10-core-defects.e2e.ts @@ -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((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); + }); +}); diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json index dad4691..92eaf60 100644 --- a/tests/e2e/known-defects.json +++ b/tests/e2e/known-defects.json @@ -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 разрешены" + } }