diff --git a/scripts/test/run-e2e.mjs b/scripts/test/run-e2e.mjs index 2fe8f68..1db7fab 100644 --- a/scripts/test/run-e2e.mjs +++ b/scripts/test/run-e2e.mjs @@ -59,7 +59,7 @@ const known = strict const byId = new Map(); for (const file of results.testResults) { for (const test of file.assertionResults) { - const id = /^\[([A-Z0-9-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED'; + const id = /^\[([A-Z0-9/-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED'; const entry = byId.get(id) ?? { passed: 0, failed: 0, tests: [] }; if (test.status === 'passed') entry.passed += 1; else if (test.status === 'failed') entry.failed += 1; diff --git a/tests/e2e/10-core-defects.e2e.ts b/tests/e2e/10-core-defects.e2e.ts index d2d8d74..f01cb91 100644 --- a/tests/e2e/10-core-defects.e2e.ts +++ b/tests/e2e/10-core-defects.e2e.ts @@ -42,7 +42,7 @@ async function accrualsFor(employeeId: string) { } describe('Основной API на реальной PostgreSQL', () => { - it('[N1] менеджер создаёт сотрудника через POST /employees → 201', async () => { + it('[K4/N1] менеджер создаёт сотрудника через POST /employees → 201', async () => { const number = `E2E-${randomUlid().slice(-8)}`; const response = await api('POST', '/employees', { token: ctx().managerToken, @@ -59,7 +59,7 @@ describe('Основной API на реальной PostgreSQL', () => { expect(rows).toHaveLength(1); }); - it('[N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => { + it('[K3/N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => { const [unit] = await createOrgUnits(1); const [employee] = await importEmployees(1); await assignHourly(employee!.id, unit!.id); @@ -88,7 +88,7 @@ describe('Основной API на реальной PostgreSQL', () => { expect(total).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR)); }); - it('[N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => { + it('[K5/N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => { const [unit] = await createOrgUnits(1); const [employee] = await importEmployees(1); await assignHourly(employee!.id, unit!.id); @@ -136,7 +136,7 @@ describe('Основной API на реальной PostgreSQL', () => { ); }); - it('[N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => { + it('[K12/N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => { const [employee] = await importEmployees(1); const employeeToken = await issueEmployeeToken(employee!.id); let connections = 0; @@ -227,7 +227,7 @@ describe('Основной API на реальной PostgreSQL', () => { }); }); - it('[F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401)', async () => { + 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', { @@ -241,7 +241,7 @@ describe('Основной API на реальной PostgreSQL', () => { }); }); - it('[F3] cookie сессии не содержит сырой токен и отзывается при выходе', async () => { + 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') ?? ''; @@ -311,7 +311,7 @@ describe('Основной API на реальной PostgreSQL', () => { expect(response.status, describeResponse(response)).toBe(400); }); - it('[F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => { + it('[K11/F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => { const statuses: number[] = []; for (let attempt = 0; attempt < 30; attempt += 1) { const response = await api('POST', '/session', { diff --git a/tests/e2e/20-sync-defects.e2e.ts b/tests/e2e/20-sync-defects.e2e.ts index 3758c80..14e10d8 100644 --- a/tests/e2e/20-sync-defects.e2e.ts +++ b/tests/e2e/20-sync-defects.e2e.ts @@ -57,7 +57,7 @@ async function pullAll(device: Device, cursor: string | null = null) { } describe('Офлайн-синхронизация на реальной PostgreSQL', () => { - it('[SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется)', async () => { + it('[K13/SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется)', async () => { const [unit] = await createOrgUnits(1); const [employee] = await importEmployees(1); const device = await registerDevice(unit!.id); @@ -86,7 +86,7 @@ describe('Офлайн-синхронизация на реальной PostgreS ).toEqual({ http: 200, result: 'rejected', stored: null }); }); - it('[SYNC-B] устройство не может утвердить табель (status=approved отклоняется)', async () => { + it('[K13/SYNC-B] устройство не может утвердить табель (status=approved отклоняется)', async () => { const [employee] = await importEmployees(1); const device = await registerDevice(); const entryId = randomUlid(); @@ -124,7 +124,7 @@ describe('Офлайн-синхронизация на реальной PostgreS expect(types.has('employee') && types.has('org_unit')).toBe(true); }); - it('[SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично', async () => { + it('[S5/SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично', async () => { const employees = await importEmployees(120); const device = await registerDevice(); const { changes } = await pullAll(device, null); @@ -135,7 +135,7 @@ describe('Офлайн-синхронизация на реальной PostgreS expect(missing, `не доставлено сотрудников: ${missing} из ${employees.length}`).toBe(0); }); - it('[SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull', async () => { + it('[S5/SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull', async () => { const device = await registerDevice(); // Курсор берём из /sync/status, чтобы сценарий не зависел от начальной загрузки (SYNC-C0). const status = await api('GET', '/sync/status', { token: device.token }); @@ -153,7 +153,7 @@ describe('Офлайн-синхронизация на реальной PostgreS ); }); - it('[SYNC-E] отметка уволенного сотрудника через sync отклоняется', async () => { + it('[S4/SYNC-E] отметка уволенного сотрудника через sync отклоняется', async () => { const [employee] = await importEmployees(1); const dismiss = await api('PATCH', `/employees/${employee!.id}`, { token: ctx().managerToken, @@ -171,7 +171,7 @@ describe('Офлайн-синхронизация на реальной PostgreS expect(response.body?.results?.[0]?.status, describeResponse(response)).toBe('rejected'); }); - it('[SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется', async () => { + it('[S4/SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется', async () => { const [unitA, unitB] = await createOrgUnits(2); const [employee] = await importEmployees(1); const device = await registerDevice(unitA!.id); @@ -202,7 +202,7 @@ describe('Офлайн-синхронизация на реальной PostgreS ).toEqual({ ownOrgUnit: unitA!.id, foreign: 'rejected' }); }); - it('[SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500)', async () => { + it('[S3/SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500)', async () => { const [employee] = await importEmployees(1); const device = await registerDevice(); const good = command('attendance_event', { @@ -225,7 +225,7 @@ describe('Офлайн-синхронизация на реальной PostgreS ).toEqual({ http: 200, statuses: ['applied', 'rejected'] }); }); - it('[SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403)', async () => { + it('[S8/SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403)', async () => { const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } }); const cookie = /(__Host-krona_staff_session=[^;]*)/.exec(login.headers.get('set-cookie') ?? '')?.[1] ?? ''; @@ -236,7 +236,7 @@ describe('Офлайн-синхронизация на реальной PostgreS expect(response.status, describeResponse(response)).toBe(403); }); - it('[SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401)', async () => { + it('[K1/SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401)', async () => { const response = await api('POST', '/sync/devices', { base: 'open', body: { @@ -247,7 +247,7 @@ describe('Офлайн-синхронизация на реальной PostgreS expect(response.status, describeResponse(response)).toBe(401); }); - it('[SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events', async () => { + it('[S2/SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events', async () => { const [employee] = await importEmployees(1); const device = await registerDevice(); const response = await push(device, [ diff --git a/tests/e2e/30-owner-decisions.e2e.ts b/tests/e2e/30-owner-decisions.e2e.ts index 18292b0..2912a63 100644 --- a/tests/e2e/30-owner-decisions.e2e.ts +++ b/tests/e2e/30-owner-decisions.e2e.ts @@ -89,7 +89,7 @@ async function ledgerFor(employeeId: string) { } describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => { - it('[OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => { + it('[D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => { const w = await world(false); const managerA = await grant('point_manager', [w.unitA.id]); const batch = await createBatch(managerA, w.unitB.id, w.day); @@ -100,7 +100,7 @@ describe('Решения владельца: роли, пакеты зарпла expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 }); }); - it('[OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => { + it('[D6/OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => { const w = await world(false); const managerA = await grant('point_manager', [w.unitA.id]); const created = await createBatch(managerA, w.unitA.id, w.day); @@ -169,7 +169,7 @@ describe('Решения владельца: роли, пакеты зарпла }).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 }); }); - it('[OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => { + it('[K19/OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => { const accountant = await grant('accountant'); const director = await grant('director'); const w = await world(); @@ -233,7 +233,7 @@ describe('Решения владельца: роли, пакеты зарпла }); }); - it('[OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => { + it('[K17/OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => { const [employee] = await importEmployees(1); const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, { token: ctx().terminalToken, @@ -242,7 +242,7 @@ describe('Решения владельца: роли, пакеты зарпла expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' }); }); - it('[OWN-11] согласие отозвано → статус для терминала меняется', async () => { + it('[K17/OWN-11] согласие отозвано → статус для терминала меняется', async () => { const [employee] = await importEmployees(1); const setConsent = (status: 'granted' | 'revoked') => api('PUT', `/employees/${employee!.id}/biometric-consent`, { diff --git a/tests/e2e/40-compose-pilot.e2e.ts b/tests/e2e/40-compose-pilot.e2e.ts index 466662c..a196cf4 100644 --- a/tests/e2e/40-compose-pilot.e2e.ts +++ b/tests/e2e/40-compose-pilot.e2e.ts @@ -27,14 +27,14 @@ function environmentOf(service: string): Record { } describe('deploy/pilot/compose.yaml', () => { - it('[PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение}', () => { + it('[K1/PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение}', () => { const interpolations = [...raw.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1]!); expect(interpolations.length).toBeGreaterThan(0); const optional = interpolations.filter((inner) => !/^[A-Z0-9_]+:\?.+$/.test(inner)); expect(optional, 'переменные без :? — пустое значение молча попадёт в контейнер').toEqual([]); }); - it('[PILOT-2] ни один сервис не запускается с NODE_ENV=development', () => { + it('[K2/PILOT-2] ни один сервис не запускается с NODE_ENV=development', () => { const offenders = Object.keys(compose.services).filter( (service) => environmentOf(service).NODE_ENV === 'development', ); diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json index b3c0cf8..4ec0e14 100644 --- a/tests/e2e/known-defects.json +++ b/tests/e2e/known-defects.json @@ -1,43 +1,43 @@ { - "comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md.", + "comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md. Ключи — двойные ID «владелец/мой» (docs/audit/2026-09-26/AUDIT.md и TASK-GROK-2 §2), для сценариев без ID владельца — мой ID.", "defects": { - "N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL", - "N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)", - "N3": "SSRF через push-endpoint на внутренние адреса", - "N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)", + "K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL", + "K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)", + "K12/N3": "SSRF через push-endpoint на внутренние адреса", + "K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)", "N9": "перепривязка чужого push-endpoint", "N10": "справочники доступны терминалу", "N11": "импорт с неверным kind → 500", "N12": "pay-assignment без additionalProperties:false и верхних границ", - "F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму", - "F3": "cookie = сырой токен, logout не отзывает", - "F4": "нет rate limit на вход", + "K1/F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму", + "K10/F3": "cookie = сырой токен, logout не отзывает", + "K11/F4": "нет rate limit на вход", "F7": "Idempotency-Key не используется", "F10": "CSRF: мутации по cookie без Origin разрешены", - "SYNC-A": "устройство само подтверждает смену через upsert_work_session", - "SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry", + "K13/SYNC-A": "устройство само подтверждает смену через upsert_work_session", + "K13/SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry", "SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)", - "SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)", - "SYNC-D": "изменения из портала не пишутся в staff_sync_change_log", - "SYNC-E": "отметки уволенных сотрудников принимаются через sync", - "SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку", - "SYNC-G": "одна битая команда роняет весь пакет (500)", - "SYNC-H": "регистрация устройства по cookie без проверки Origin", - "SYNC-I": "без токенов анонимно регистрируется устройство", - "SYNC-J": "sync принимает отметки вне окна времени", + "S5/SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)", + "S5/SYNC-D": "изменения из портала не пишутся в staff_sync_change_log", + "S4/SYNC-E": "отметки уволенных сотрудников принимаются через sync", + "S4/SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку", + "S3/SYNC-G": "одна битая команда роняет весь пакет (500)", + "S8/SYNC-H": "регистрация устройства по cookie без проверки Origin", + "K1/SYNC-I": "без токенов анонимно регистрируется устройство", + "S2/SYNC-J": "sync принимает отметки вне окна времени", "SYNC-K": "ack принимает курсор больше выданного сервером", - "PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)", - "PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development", - "OWN-01": "решение владельца: роли точки (этап 4 Codex)", - "OWN-02": "решение владельца: пакет отправляет только бухгалтер", + "K1/PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)", + "K2/PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development", + "D6/OWN-01": "решение владельца: роли точки (этап 4 Codex)", + "D6/OWN-02": "решение владельца: пакет отправляет только бухгалтер", "OWN-03": "решение владельца: уведомления директорам", "OWN-04": "решение владельца: подписывает только директор", "OWN-05": "решение владельца: ledger пуст до подписи", "OWN-06": "решение владельца: подпись → ledger, неизменяемость", - "OWN-07": "решение владельца: изменение смены после отправки возвращает пакет", + "K19/OWN-07": "решение владельца: изменение смены после отправки возвращает пакет", "OWN-08": "решение владельца: возврат с комментарием", "OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки", - "OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена", - "OWN-11": "решение владельца: отзыв согласия меняет статус для терминала" + "K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена", + "K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала" } }