// Сценарии дефектов основного 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('[K4/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('[K3/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('[K5/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('[K12/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('[K1/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('[K10/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('[K11/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); }); });