diff --git a/tests/e2e/30-owner-decisions.e2e.ts b/tests/e2e/30-owner-decisions.e2e.ts new file mode 100644 index 0000000..18292b0 --- /dev/null +++ b/tests/e2e/30-owner-decisions.e2e.ts @@ -0,0 +1,270 @@ +// Сценарии решений владельца (ожидаемо КРАСНЫЕ до этапа 4 Codex). +// Публичного API для ролей точки/бухгалтера/директора, пакетов зарплаты и согласия +// на биометрию пока нет, поэтому тесты написаны против наиболее вероятного контракта, +// описанного в tests/e2e/NOTES-FOR-EXECUTORS.md (C1–C3). Если Codex выберет другой +// контракт — меняется только этот файл и NOTES, смысл проверок остаётся. +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ +import { describe, expect, it } from 'vitest'; +import { api, ctx, dbQuery, describeResponse } from './harness/client.js'; +import { synthetic } from './harness/context.js'; +import { + assignHourly, + createOrgUnits, + daysAgo, + importEmployees, + isoDate, + workedShift, + type OrgUnit, +} from './harness/fixtures.js'; + +type Role = 'point_manager' | 'accountant' | 'director'; + +/** C1: главный менеджер выдаёт ключ роли (standalone-аналог Core RBAC). */ +async function grant(role: Role, orgUnitIds: string[] = []): Promise { + const response = await api('POST', '/access-grants', { + token: ctx().managerToken, + body: { role, org_unit_ids: orgUnitIds, display_name: synthetic(role) }, + }); + expect(response.status, `C1 POST /access-grants (${role}): ${describeResponse(response)}`).toBe( + 201, + ); + return response.body.access_token as string; +} + +async function confirm(sessionId: string) { + const response = await api('PATCH', `/work-sessions/${sessionId}`, { + token: ctx().managerToken, + body: { status: 'confirmed' }, + }); + expect(response.status, `подтверждение смены (см. N2): ${describeResponse(response)}`).toBe(200); +} + +interface World { + unitA: OrgUnit; + unitB: OrgUnit; + employeeId: string; + sessionId: string; + day: Date; +} + +/** Точка A и B, сотрудник точки A со сменой в точке A (по умолчанию подтверждённой; см. N2). */ +async function world(confirmShift = true): Promise { + const [unitA, unitB] = await createOrgUnits(2); + const [employee] = await importEmployees(1); + await assignHourly(employee!.id, unitA!.id); + const day = daysAgo(4); + const sessionId = await workedShift(employee!.id, unitA!.id, day, 8); + if (confirmShift) await confirm(sessionId); + return { unitA: unitA!, unitB: unitB!, employeeId: employee!.id, sessionId, day }; +} + +function period(day: Date) { + const from = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), 1)); + const to = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth() + 1, 0)); + return { from: isoDate(from), to: isoDate(to) }; +} + +/** C2: пакет зарплаты точки за месяц. */ +async function createBatch(token: string, orgUnitId: string, day: Date) { + return api('POST', '/payroll/batches', { + token, + body: { org_unit_id: orgUnitId, ...period(day) }, + }); +} + +async function submittedBatch(w: World, accountant: string): Promise { + const created = await createBatch(accountant, w.unitA.id, w.day); + expect(created.status, `C2 создание пакета: ${describeResponse(created)}`).toBe(201); + const batchId = created.body.id as string; + const submit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant }); + expect(submit.status, `C2 отправка пакета: ${describeResponse(submit)}`).toBe(200); + return batchId; +} + +async function ledgerFor(employeeId: string) { + return dbQuery<{ kind: string; amount_minor: string }>( + `SELECT kind, amount_minor::text AS amount_minor FROM staff_payroll_ledger WHERE employee_id = $1`, + [employeeId], + ); +} + +describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => { + it('[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); + const legacy = await api('POST', '/payroll/calculate', { + token: managerA, + body: { month: isoDate(w.day).slice(0, 7), org_unit_id: w.unitB.id }, + }); + expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 }); + }); + + it('[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); + expect( + created.status, + `C2 создание пакета менеджером своей точки: ${describeResponse(created)}`, + ).toBe(201); + const submit = await api('POST', `/payroll/batches/${created.body.id}/submit`, { + token: managerA, + }); + expect(submit.status, describeResponse(submit)).toBe(403); + }); + + it('[OWN-03] бухгалтер отправляет пакет → успех, каждый директор получает ровно одно уведомление', async () => { + const accountant = await grant('accountant'); + const directors = [await grant('director'), await grant('director')]; + const w = await world(); + const batchId = await submittedBatch(w, accountant); + for (const director of directors) { + const inbox = await api('GET', '/notifications', { token: director }); + expect(inbox.status, `C2 уведомления директора: ${describeResponse(inbox)}`).toBe(200); + const aboutBatch = ( + inbox.body.notifications as Array<{ type: string; subject_id: string }> + ).filter((n) => n.type === 'payroll.batch.submitted' && n.subject_id === batchId); + expect(aboutBatch).toHaveLength(1); + } + }); + + it('[OWN-04] бухгалтер пытается подписать пакет → 403', async () => { + const accountant = await grant('accountant'); + const w = await world(); + const batchId = await submittedBatch(w, accountant); + const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: accountant }); + expect(sign.status, describeResponse(sign)).toBe(403); + }); + + it('[OWN-05] до подписи директора в ledger нет начислений и выплат', async () => { + const accountant = await grant('accountant'); + const w = await world(); + await submittedBatch(w, accountant); + expect(await ledgerFor(w.employeeId)).toEqual([]); + }); + + it('[OWN-06] подпись директора → начисления и выплаты в ledger, пакет и смены неизменяемы', async () => { + const accountant = await grant('accountant'); + const director = await grant('director'); + const w = await world(); + const batchId = await submittedBatch(w, accountant); + const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director }); + expect(sign.status, describeResponse(sign)).toBe(200); + const kinds = new Set((await ledgerFor(w.employeeId)).map((row) => row.kind)); + const editShift = await api('PATCH', `/work-sessions/${w.sessionId}`, { + token: ctx().managerToken, + body: { + status: 'confirmed', + worked_minutes: 1, + correction_note: 'E2E попытка правки после подписи', + }, + }); + const resubmit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant }); + expect({ + accrual: kinds.has('accrual'), + payout: kinds.has('payout'), + editShift: editShift.status, + resubmit: resubmit.status, + }).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 }); + }); + + it('[OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => { + const accountant = await grant('accountant'); + const director = await grant('director'); + const w = await world(); + const batchId = await submittedBatch(w, accountant); + const edit = await api('PATCH', `/work-sessions/${w.sessionId}`, { + token: ctx().managerToken, + body: { + status: 'confirmed', + worked_minutes: 420, + correction_note: 'E2E правка после отправки', + }, + }); + expect(edit.status, describeResponse(edit)).toBe(200); + const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director }); + const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant }); + expect({ sign: sign.status, status: batch.body?.status }).toEqual({ + sign: 409, + status: 'returned', + }); + }); + + it('[OWN-08] возврат с комментарием → пакет у бухгалтера, комментарий сохранён', async () => { + const accountant = await grant('accountant'); + const director = await grant('director'); + const w = await world(); + const batchId = await submittedBatch(w, accountant); + const comment = synthetic('Комментарий директора'); + const back = await api('POST', `/payroll/batches/${batchId}/return`, { + token: director, + body: { comment }, + }); + expect(back.status, describeResponse(back)).toBe(200); + const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant }); + expect({ status: batch.body?.status, comment: batch.body?.return_comment }).toEqual({ + status: 'returned', + comment, + }); + }); + + it('[OWN-09] сотрудник точки A отработал смену в точке B → смена попадает в зарплату точки B', async () => { + const accountant = await grant('accountant'); + const [unitA, unitB] = await createOrgUnits(2); + const [employee] = await importEmployees(1); + await assignHourly(employee!.id, unitA!.id); + const day = daysAgo(5); + const sessionId = await workedShift(employee!.id, unitB!.id, day, 6); + await confirm(sessionId); + const batchB = await createBatch(accountant, unitB!.id, day); + const batchA = await createBatch(accountant, unitA!.id, day); + expect(batchB.status, describeResponse(batchB)).toBe(201); + const sessionsOf = (batch: typeof batchB) => + ((batch.body?.lines ?? []) as Array<{ work_session_id: string }>).map( + (l) => l.work_session_id, + ); + expect({ + inB: sessionsOf(batchB).includes(sessionId), + inA: sessionsOf(batchA).includes(sessionId), + }).toEqual({ + inB: true, + inA: false, + }); + }); + + it('[OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => { + const [employee] = await importEmployees(1); + const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, { + token: ctx().terminalToken, + }); + expect(response.status, `C3: ${describeResponse(response)}`).toBe(200); + expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' }); + }); + + it('[OWN-11] согласие отозвано → статус для терминала меняется', async () => { + const [employee] = await importEmployees(1); + const setConsent = (status: 'granted' | 'revoked') => + api('PUT', `/employees/${employee!.id}/biometric-consent`, { + token: ctx().managerToken, + body: { status, document_ref: synthetic('согласие') }, + }); + const enrollment = () => + api('GET', `/terminal/employees/${employee!.id}/enrollment`, { token: ctx().terminalToken }); + const granted = await setConsent('granted'); + expect(granted.status, `C3 PUT biometric-consent: ${describeResponse(granted)}`).toBe(200); + const before = await enrollment(); + const revoked = await setConsent('revoked'); + expect(revoked.status, describeResponse(revoked)).toBe(200); + const after = await enrollment(); + expect({ + before: before.body?.registration_allowed, + after: after.body?.registration_allowed, + reason: after.body?.reason, + }).toEqual({ + before: true, + after: false, + reason: 'consent_revoked', + }); + }); +}); diff --git a/tests/e2e/NOTES-FOR-EXECUTORS.md b/tests/e2e/NOTES-FOR-EXECUTORS.md new file mode 100644 index 0000000..72a8ae3 --- /dev/null +++ b/tests/e2e/NOTES-FOR-EXECUTORS.md @@ -0,0 +1,121 @@ +# Заметки для исполнителей (Codex / Antigravity) + +Автор: Grok (стенд тестов и независимая приёмка). Я не меняю `src/`, `apps/`, +`migrations/`, `deploy/`; всё, что требует изменения продукта, описано здесь. + +Как запускать: + +```sh +# bootstrap-админ PostgreSQL 17 (только создаёт роли и БД; сервер и миграции под ним не работают) +export E2E_PG_ADMIN_URL=postgres://:@127.0.0.1:55433/postgres +node scripts/test/run-e2e.mjs # храповик known-defects (как в CI) +node scripts/test/run-e2e.mjs --no-known-defects # строгий режим: все дефекты красные +E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # + откат всех миграций перед запуском +./scripts/test/ci-local.sh # локальный эквивалент verify/format/e2e +``` + +**Правило храповика.** Исправили дефект → его сценарий позеленел → +`run-e2e.mjs` падает с «дефект исправлен, обновите known-defects.json» — +удалите ID из `tests/e2e/known-defects.json` в том же коммите. Новый красный +сценарий, которого нет в списке, — регрессия. + +## 1. Предполагаемый контракт для решений владельца (OWN-01..11) — Codex, этап 4 + +API пока нет, поэтому сценарии `tests/e2e/30-owner-decisions.e2e.ts` написаны +против наиболее вероятного контракта. Если выбираете другой — поменяйте тесты и +этот раздел в том же PR, смысл проверок (кто что может и когда) менять нельзя. +Все пути относительно `/api/v1/staff`. + +### C1. Роли точки/бухгалтера/директора (standalone-аналог Core RBAC) + +- `POST /access-grants` (только главный менеджер, `staff:employee:write`) + тело `{ "role": "point_manager" | "accountant" | "director", "org_unit_ids": [""], "display_name": "..." }` + → `201 { "id", "role", "org_unit_ids", "access_token" }`. Токен показывается один раз, + хранится как хеш (как ключи сотрудников), используется как `Authorization: Bearer`. +- `point_manager` видит/считает только свои `org_unit_ids`; чужая точка → `403` + (и для нового `POST /payroll/batches`, и для старого `POST /payroll/calculate`). +- В platform-режиме те же роли приходят из Core (User ≠ Employee, AGENTS.md). + +### C2. Пакет зарплаты точки и согласование + +| Метод и путь | Кто | Результат | +| ------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `POST /payroll/batches` `{org_unit_id, from, to}` | accountant; point_manager своей точки | `201 {id, status:"draft", org_unit_id, lines:[{work_session_id, employee_id, org_unit_id, amount_minor}]}` | +| `POST /payroll/batches/:id/submit` | только accountant | `200 {status:"submitted"}`; point_manager → `403`; повтор после подписи → `409` | +| `GET /notifications` | любой пользователь | `200 {notifications:[{type:"payroll.batch.submitted", subject_id:}]}`; каждому директору ровно одно уведомление на отправку | +| `POST /payroll/batches/:id/sign` | только director | `200 {status:"signed"}`; accountant → `403`; если смена пакета изменилась после отправки → `409`, пакет получает `status:"returned"` | +| `POST /payroll/batches/:id/return` `{comment}` | director | `200 {status:"returned", return_comment}` | +| `GET /payroll/batches/:id` | accountant, director | `200 {status, return_comment, lines:[...]}` | + +Инварианты, которые проверяют тесты: + +- до подписи в `staff_payroll_ledger` для сотрудников пакета нет ни `accrual`, ни `payout` (OWN-05); +- подпись атомарно пишет `accrual` и `payout`; после подписи `PATCH /work-sessions/:id` + для смен пакета → `409`, пакет не отправляется повторно (OWN-06); +- строка пакета принадлежит точке, где отработана смена (`staff_work_sessions.org_unit_id`), + а не основной точке сотрудника (OWN-09). + +### C3. Согласие на биометрию + +- `PUT /employees/:id/biometric-consent` `{ "status": "granted" | "revoked", "document_ref": "..." }` + (главный менеджер) → `200`. +- `GET /terminal/employees/:id/enrollment` (терминальный токен) → + `200 { "employee_id", "registration_allowed": boolean, "reason": "consent_missing" | "consent_revoked" | null }`. + Без записи о согласии — `false/consent_missing` (OWN-10); после отзыва — `false/consent_revoked` (OWN-11). +- Шаблоны отпечатков на сервер не передаются (docs/TERMINAL-API-REQUIREMENTS.md); + сервер хранит только факт согласия. Тот же флаг стоит отдать в `/sync/pull` (employee payload). + +Сейчас все OWN красные с `404 Route ... not found` (C1–C3 не реализованы); +OWN-03..09 дополнительно упрутся в N2 (подтверждение смены падает 500). + +## 2. Дефекты продукта, найденные сценариями (все красные сейчас) + +Команда и фактический вывод — `tests/e2e/evidence/e2e-run.txt`; таблица — `tests/e2e/REPORT.md`. + +| ID | Файл | Что исправить | +| -------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| N1 | `src/portal.ts` POST /employees | `42P08 inconsistent types deduced for parameter $2`: явные `$2::varchar(26)`, `$5::text` или отдельный запрос профессии | +| N2 | `src/portal.ts` PATCH /work-sessions, `src/domain/payroll/service.ts` calculate/approve | `staff_outbox.occurred_at` NULL (23502): передавать `occurred_at` или `DEFAULT now()` новой миграцией | +| N4 | `service.ts` | после N2 проверить: пересекающийся период не должен начислять смену повторно (409 или исключение уже начисленных смен) | +| N3 | push-подписки | allowlist push-сервисов / запрет приватных адресов, `redirect:'manual'`; сейчас сервер подключается к `127.0.0.1` | +| N9 | `push-dispatcher.ts` | `ON CONFLICT ... DO UPDATE SET employee_id` → 409 для чужого endpoint | +| N10 | GET /professions, /org-units | требовать право справочника; терминал сейчас получает 200 | +| N11 | импорт | валидировать `kind` и схему `items` → 400 вместо 500 (23514) | +| N12 | PUT pay-assignment | `additionalProperties:false`, верхние границы сумм | +| F2 | `config.ts`/`identity.ts` | standalone без токенов не должен выдавать права (процесс без токенов отдаёт 200 на /portal, /payroll, POST /professions) | +| F3 | POST/DELETE /session | в cookie — идентификатор сессии, а не сырой токен; logout отзывает сессию | +| F4 | вход | rate limit (тест ждёт 429 не позже 30 неверных попыток; идёт на отдельном процессе, чтобы лимит не мешал другим сценариям) | +| F7 | POST /attendance/events | использовать `Idempotency-Key` / `staff_idempotency_keys` | +| F10 | `assertSameOrigin` | мутации по cookie без `Origin` отклонять | +| SYNC-A/B | `src/domain/sync/service.ts` | устройство не должно выставлять `confirmed`/`approved` (сейчас `applied`) | +| SYNC-C0 | `pull()` bootstrap | `SELECT ... status FROM staff_org_units` — такого столбца нет → 500 на любой начальной загрузке | +| SYNC-C | `pull()` bootstrap | при `has_more=true` отдаётся `next_cursor=latest`, остаток после 100 сущностей теряется; нужен курсор снимка | +| SYNC-D | портал/импорт | изменения вне sync не пишутся в `staff_sync_change_log` → терминал не узнаёт о новых/изменённых сотрудниках | +| SYNC-E | `executeCommand` | проверять `status='active'` сотрудника | +| SYNC-F | `executeCommand` | сохранять `org_unit_id` в `staff_attendance_events`; payload не может переопределить точку устройства | +| SYNC-G | `push()` | ошибки БД одной команды → `rejected` для неё, а не 500 и откат всего пакета | +| SYNC-H | `/sync/devices` | `assertCookieMutationOrigin` как в портале | +| SYNC-I | `requireManagerContext` | без токенов не выдавать права (как F2) | +| SYNC-J | `executeCommand` | окно времени отметки как в POST /attendance/events (−180 дней … +5 мин) | +| SYNC-K | `acknowledge()` | отклонять курсор больше `MAX(cursor_seq)` | + +## 3. deploy/ (PILOT-1, PILOT-2) — Codex + +- PILOT-1: все подстановки в `deploy/pilot/compose.yaml` → `${VAR:?VAR is required}` + (сейчас 14 без `:?`: пустой `STANDALONE_MANAGER_TOKEN` молча превращает pilot в F2). +- PILOT-2: `NODE_ENV: development` в `app`. Конфликт: `config.ts` запрещает + `STAFF_RUNTIME_MODE=standalone` при `NODE_ENV=production`. Нужно решение: либо + разрешить standalone в production при обязательных токенах и HTTPS, либо + отдельный режим для pilot. Тест требует только «не development». +- N6: `scripts/create-app-role.mjs` не работает мигратором без SUPERUSER + («Only roles with SUPERUSER may change the SUPERUSER attribute»). CI и e2e + обходятся `scripts/test/provision-pg.mjs` (роли создаёт bootstrap-админ, как + `docker/postgres/init-app-role.sh`). В pilot мигратор = `POSTGRES_USER` = + суперпользователь; рекомендую схему как в provision-pg.mjs. + +## 4. CI + +- Задаче `docker-build` нужен раннер с меткой `docker-host` (Docker daemon). + Без него задача ждёт в очереди; остальные задачи не блокируются. +- `format` красная, пока не исправлен F14 (25 файлов, в основном `src/`, `docs/`, `tests/unit/`). +- Реальный Forgejo-раннер мной не запускался (нет API-токена) — не проверено. diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json index 39086ed..b3c0cf8 100644 --- a/tests/e2e/known-defects.json +++ b/tests/e2e/known-defects.json @@ -27,6 +27,17 @@ "SYNC-J": "sync принимает отметки вне окна времени", "SYNC-K": "ack принимает курсор больше выданного сервером", "PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)", - "PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development" + "PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development", + "OWN-01": "решение владельца: роли точки (этап 4 Codex)", + "OWN-02": "решение владельца: пакет отправляет только бухгалтер", + "OWN-03": "решение владельца: уведомления директорам", + "OWN-04": "решение владельца: подписывает только директор", + "OWN-05": "решение владельца: ledger пуст до подписи", + "OWN-06": "решение владельца: подпись → ledger, неизменяемость", + "OWN-07": "решение владельца: изменение смены после отправки возвращает пакет", + "OWN-08": "решение владельца: возврат с комментарием", + "OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки", + "OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена", + "OWN-11": "решение владельца: отзыв согласия меняет статус для терминала" } }