Compare commits
2 commits
codex/boot
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1573860280 | ||
|
|
a05777c59f |
98 changed files with 469 additions and 10596 deletions
|
|
@ -11,6 +11,5 @@ PLATFORM_GATEWAY_URL=http://host.docker.internal:3000
|
||||||
PLATFORM_JWT_ISSUER=https://platform.example.invalid
|
PLATFORM_JWT_ISSUER=https://platform.example.invalid
|
||||||
PLATFORM_JWT_AUDIENCE=krona-staff
|
PLATFORM_JWT_AUDIENCE=krona-staff
|
||||||
PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json
|
PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json
|
||||||
# Set this to the organization's tenant ID from Krona Business Platform.
|
STANDALONE_TENANT_ID=01J00000000000000000000000
|
||||||
STANDALONE_TENANT_ID=
|
|
||||||
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,6 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: [main, codex/bootstrap-platform-foundation]
|
branches: [main, codex/bootstrap-platform-foundation]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
# Все actions закреплены полным SHA коммита (F13); SHA получены `git ls-remote`
|
|
||||||
# и совпадают на github.com и code.forgejo.org (зеркало), поэтому закрепление
|
|
||||||
# работает при любом DEFAULT_ACTIONS_URL раннера.
|
|
||||||
#
|
|
||||||
# Роли БД как в pilot, но с минимальными привилегиями: POSTGRES_USER сервиса —
|
|
||||||
# только bootstrap-админ (роль DBA/entrypoint образа), им scripts/test/provision-pg.mjs
|
|
||||||
# создаёт krona_migrator (NOSUPERUSER, владелец БД) и krona_staff_app (NOBYPASSRLS)
|
|
||||||
# со случайными паролями. Ни миграции, ни тесты, ни сервер под bootstrap-админом не работают.
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
|
@ -20,125 +10,27 @@ jobs:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: krona_staff
|
||||||
POSTGRES_USER: ci_bootstrap
|
POSTGRES_USER: krona_migrator
|
||||||
POSTGRES_PASSWORD: ci-bootstrap-only
|
POSTGRES_PASSWORD: ci-migration-password
|
||||||
|
ports: ['5432:5432']
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "pg_isready -U ci_bootstrap -d postgres"
|
--health-cmd "pg_isready -U krona_migrator -d krona_staff"
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 3s
|
--health-timeout 3s
|
||||||
--health-retries 20
|
--health-retries 20
|
||||||
env:
|
env:
|
||||||
E2E_PG_ADMIN_URL: postgres://ci_bootstrap:ci-bootstrap-only@postgres:5432/postgres
|
DATABASE_MIGRATION_URL: postgres://krona_migrator:ci-migration-password@postgres:5432/krona_staff
|
||||||
|
DATABASE_TEST_URL: postgres://krona_staff_app:ci-app-password@postgres:5432/krona_staff
|
||||||
|
POSTGRES_APP_PASSWORD: ci-app-password
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: 22, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
- name: provision roles (migrator NOSUPERUSER, app NOBYPASSRLS)
|
|
||||||
run: node scripts/test/provision-pg.mjs --out "$RUNNER_TEMP/db.env" --db krona_staff
|
|
||||||
- name: migrations up -> rollback all -> up -> up (no-op)
|
|
||||||
run: |
|
|
||||||
set -a; . "$RUNNER_TEMP/db.env"; set +a
|
|
||||||
node scripts/test/migrate-roundtrip.mjs roundtrip
|
|
||||||
- name: npm run check (tenant-isolation on real PostgreSQL as app role)
|
|
||||||
run: |
|
|
||||||
set -a; . "$RUNNER_TEMP/db.env"; set +a
|
|
||||||
unset DATABASE_URL POSTGRES_APP_PASSWORD
|
|
||||||
npm run check
|
|
||||||
- run: npm audit --audit-level=high
|
|
||||||
|
|
||||||
# Отдельная задача: prettier сейчас красный по 23 файлам (F14; замер 2026-09-26), и это не должно
|
|
||||||
# маскировать статус verify/e2e.
|
|
||||||
format:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
with: { node-version: 22, cache: npm }
|
with: { node-version: 22, cache: npm }
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- run: node scripts/create-app-role.mjs
|
||||||
|
- run: node scripts/migrate.mjs up
|
||||||
|
- run: npm run check
|
||||||
- run: npm run format:check
|
- run: npm run format:check
|
||||||
|
- run: npm audit --audit-level=high
|
||||||
# Приёмочные сценарии на реальном сервере и PostgreSQL 17. Красные сценарии
|
- run: docker build -t krona-staff:${{ forgejo.sha }} .
|
||||||
# известных открытых дефектов перечислены в tests/e2e/known-defects.json:
|
|
||||||
# задача зелёная, если стенд работает ([HARNESS]/[CTRL] зелёные) и каждый
|
|
||||||
# красный сценарий есть в списке; новый красный сценарий или исправленный,
|
|
||||||
# но не вычеркнутый дефект делают задачу красной.
|
|
||||||
e2e:
|
|
||||||
runs-on: docker
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
|
||||||
env:
|
|
||||||
POSTGRES_DB: postgres
|
|
||||||
POSTGRES_USER: ci_bootstrap
|
|
||||||
POSTGRES_PASSWORD: ci-bootstrap-only
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U ci_bootstrap -d postgres"
|
|
||||||
--health-interval 5s
|
|
||||||
--health-timeout 3s
|
|
||||||
--health-retries 20
|
|
||||||
env:
|
|
||||||
E2E_PG_ADMIN_URL: postgres://ci_bootstrap:ci-bootstrap-only@postgres:5432/postgres
|
|
||||||
E2E_MIGRATIONS: roundtrip
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
with: { node-version: 22, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
- name: e2e (known-defects ratchet)
|
|
||||||
run: node scripts/test/run-e2e.mjs
|
|
||||||
- name: e2e table
|
|
||||||
if: always()
|
|
||||||
run: cat tests/e2e/evidence/e2e-table.md || true
|
|
||||||
# tests/e2e/evidence/ в .gitignore: полный вывод vitest, JSON, логи серверов —
|
|
||||||
# артефакт прогона, а не файлы репозитория (TASK-GROK-2 п.1).
|
|
||||||
- name: upload e2e evidence
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: e2e-evidence
|
|
||||||
path: tests/e2e/evidence/
|
|
||||||
retention-days: 14
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
# Windows 11 (проект разрабатывается под Windows). Windows-раннера у сервера пока
|
|
||||||
# НЕТ, поэтому задача включается только переменной репозитория
|
|
||||||
# KRONA_WINDOWS_RUNNER=true (иначе пропускается и не висит в очереди).
|
|
||||||
# Раннер: forgejo-runner в режиме host с меткой `windows`, Node 22+.
|
|
||||||
# e2e на Windows идёт, только если задан секрет WINDOWS_E2E_PG_ADMIN_URL
|
|
||||||
# (bootstrap-админ локальной PostgreSQL 17 на раннере). НЕ ПРОВЕРЕНО: не запускалась.
|
|
||||||
windows:
|
|
||||||
if: vars.KRONA_WINDOWS_RUNNER == 'true'
|
|
||||||
runs-on: windows
|
|
||||||
env:
|
|
||||||
E2E_PG_ADMIN_URL: ${{ secrets.WINDOWS_E2E_PG_ADMIN_URL }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
with: { node-version: 22, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
- name: spawn without .cmd wrappers
|
|
||||||
run: node node_modules/vitest/vitest.mjs run tests/e2e/harness/node-commands.test.ts
|
|
||||||
- name: e2e on Windows (needs local PostgreSQL 17)
|
|
||||||
if: env.E2E_PG_ADMIN_URL != ''
|
|
||||||
run: node scripts/test/run-e2e.mjs
|
|
||||||
- name: upload Windows e2e evidence
|
|
||||||
if: always() && env.E2E_PG_ADMIN_URL != ''
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: e2e-evidence-windows
|
|
||||||
path: tests/e2e/evidence/
|
|
||||||
retention-days: 14
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
# docker build требует Docker daemon, которого нет в контейнере метки `docker`.
|
|
||||||
# Нужен раннер с меткой `docker-host` (хост с доступом к Docker). Пока такого
|
|
||||||
# раннера нет, задача ждёт в очереди и не блокирует verify/format/e2e.
|
|
||||||
docker-build:
|
|
||||||
runs-on: docker-host
|
|
||||||
needs: [verify]
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
||||||
- run: docker build -t "krona-staff:${GITHUB_SHA}" .
|
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -18,6 +18,3 @@ apps/KronaStaffTerminal.Tests/bin/
|
||||||
apps/KronaStaffTerminal.Tests/obj/
|
apps/KronaStaffTerminal.Tests/obj/
|
||||||
artifacts/
|
artifacts/
|
||||||
.audit-build/
|
.audit-build/
|
||||||
|
|
||||||
# Результаты прогонов e2e (логи серверов, JSON vitest, таблица) — артефакт CI, не исходники
|
|
||||||
tests/e2e/evidence/
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Перед следующей командой заполните STANDALONE_TENANT_ID в .env:
|
|
||||||
# используйте ULID организации из Krona Business Platform, не ID сотрудника.
|
|
||||||
docker compose --profile tools run --rm migrate
|
docker compose --profile tools run --rm migrate
|
||||||
docker compose up --build app postgres
|
docker compose up --build app postgres
|
||||||
```
|
```
|
||||||
|
|
@ -24,7 +22,7 @@ docker compose up --build app postgres
|
||||||
- `npm run db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`.
|
- `npm run db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`.
|
||||||
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
|
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
|
||||||
|
|
||||||
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), запуск и чек-лист боевого тестирования: [docs/RUN-MANUAL-TESTING.md](docs/RUN-MANUAL-TESTING.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
|
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||||
|
|
||||||
## Расчётный контур
|
## Расчётный контур
|
||||||
|
|
||||||
|
|
|
||||||
170
api/openapi.yaml
170
api/openapi.yaml
|
|
@ -74,26 +74,6 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
'204': { description: Сессия завершена }
|
'204': { description: Сессия завершена }
|
||||||
'403': { description: Запрос с постороннего сайта отклонён }
|
'403': { description: Запрос с постороннего сайта отклонён }
|
||||||
/access-grants:
|
|
||||||
post:
|
|
||||||
summary: Выпустить ключ доступа для роли
|
|
||||||
operationId: createStaffAccessGrant
|
|
||||||
tags: [Authentication]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [role, display_name]
|
|
||||||
properties:
|
|
||||||
role: { type: string, enum: [point_manager, accountant, director] }
|
|
||||||
display_name: { type: string, minLength: 1, maxLength: 255 }
|
|
||||||
org_unit_ids: { type: array, items: { type: string } }
|
|
||||||
responses:
|
|
||||||
'201': { description: Ключ выпущен }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
/portal:
|
/portal:
|
||||||
get:
|
get:
|
||||||
summary: Получить доступную пользователю сводку посещаемости и табеля
|
summary: Получить доступную пользователю сводку посещаемости и табеля
|
||||||
|
|
@ -331,30 +311,6 @@ paths:
|
||||||
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
|
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
|
||||||
'403': { description: Недостаточно прав }
|
'403': { description: Недостаточно прав }
|
||||||
'404': { description: Сотрудник не найден }
|
'404': { description: Сотрудник не найден }
|
||||||
/employees/{employeeId}/biometric-consent:
|
|
||||||
put:
|
|
||||||
summary: Установить согласие сотрудника на обработку биометрии
|
|
||||||
operationId: setEmployeeBiometricConsent
|
|
||||||
tags: [Employees]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: employeeId
|
|
||||||
required: true
|
|
||||||
schema: { $ref: '#/components/schemas/Ulid' }
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [status]
|
|
||||||
properties:
|
|
||||||
status: { type: string, enum: [granted, revoked] }
|
|
||||||
document_ref: { type: string }
|
|
||||||
responses:
|
|
||||||
'200': { description: Согласие сохранено }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
/employees/{employeeId}/pay-assignment:
|
/employees/{employeeId}/pay-assignment:
|
||||||
get:
|
get:
|
||||||
summary: Получить зарплатный профиль сотрудника
|
summary: Получить зарплатный профиль сотрудника
|
||||||
|
|
@ -416,15 +372,6 @@ paths:
|
||||||
type: array
|
type: array
|
||||||
items: { type: object }
|
items: { type: object }
|
||||||
'403': { description: Недостаточно прав }
|
'403': { description: Недостаточно прав }
|
||||||
/notifications:
|
|
||||||
get:
|
|
||||||
summary: Получить уведомления
|
|
||||||
operationId: getBatchNotifications
|
|
||||||
tags: [Notifications]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
responses:
|
|
||||||
'200': { description: Список уведомлений }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
/org-units:
|
/org-units:
|
||||||
get:
|
get:
|
||||||
summary: Получить доступные торговые точки
|
summary: Получить доступные торговые точки
|
||||||
|
|
@ -529,92 +476,6 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
'200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' }
|
'200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' }
|
||||||
'403': { description: Недостаточно прав }
|
'403': { description: Недостаточно прав }
|
||||||
/payroll/batches:
|
|
||||||
post:
|
|
||||||
summary: Сформировать зарплатный пакет подразделения
|
|
||||||
operationId: createPayrollBatch
|
|
||||||
tags: [Payroll]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [org_unit_id, from, to]
|
|
||||||
properties:
|
|
||||||
org_unit_id: { $ref: '#/components/schemas/Ulid' }
|
|
||||||
from: { type: string, format: date }
|
|
||||||
to: { type: string, format: date }
|
|
||||||
responses:
|
|
||||||
'201': { description: Пакет сформирован }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
/payroll/batches/{id}:
|
|
||||||
get:
|
|
||||||
summary: Получить зарплатный пакет
|
|
||||||
operationId: getPayrollBatch
|
|
||||||
tags: [Payroll]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: id
|
|
||||||
required: true
|
|
||||||
schema: { $ref: '#/components/schemas/Ulid' }
|
|
||||||
responses:
|
|
||||||
'200': { description: Зарплатный пакет }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
'404': { description: Пакет не найден }
|
|
||||||
/payroll/batches/{id}/submit:
|
|
||||||
post:
|
|
||||||
summary: Отправить зарплатный пакет на подпись
|
|
||||||
operationId: submitPayrollBatch
|
|
||||||
tags: [Payroll]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: id
|
|
||||||
required: true
|
|
||||||
schema: { $ref: '#/components/schemas/Ulid' }
|
|
||||||
responses:
|
|
||||||
'200': { description: Пакет отправлен }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
'409': { description: Пакет уже подписан }
|
|
||||||
/payroll/batches/{id}/sign:
|
|
||||||
post:
|
|
||||||
summary: Подписать зарплатный пакет и начислить в реестр
|
|
||||||
operationId: signPayrollBatch
|
|
||||||
tags: [Payroll]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: id
|
|
||||||
required: true
|
|
||||||
schema: { $ref: '#/components/schemas/Ulid' }
|
|
||||||
responses:
|
|
||||||
'200': { description: Пакет подписан }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
'409': { description: Пакет возвращён из-за изменения смен }
|
|
||||||
/payroll/batches/{id}/return:
|
|
||||||
post:
|
|
||||||
summary: Вернуть зарплатный пакет на доработку
|
|
||||||
operationId: returnPayrollBatch
|
|
||||||
tags: [Payroll]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: id
|
|
||||||
required: true
|
|
||||||
schema: { $ref: '#/components/schemas/Ulid' }
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
comment: { type: string }
|
|
||||||
responses:
|
|
||||||
'200': { description: Пакет возвращён }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
/payroll/calculate:
|
/payroll/calculate:
|
||||||
post:
|
post:
|
||||||
summary: Рассчитать зарплату за период
|
summary: Рассчитать зарплату за период
|
||||||
|
|
@ -706,20 +567,6 @@ paths:
|
||||||
lines: { type: array, items: { type: object } }
|
lines: { type: array, items: { type: object } }
|
||||||
'403': { description: Недостаточно прав }
|
'403': { description: Недостаточно прав }
|
||||||
'404': { description: Расчёт не найден }
|
'404': { description: Расчёт не найден }
|
||||||
/payroll/runs/{runId}/verify:
|
|
||||||
get:
|
|
||||||
summary: Проверить воспроизводимость расчёта зарплаты
|
|
||||||
operationId: verifyStaffPayrollRun
|
|
||||||
tags: [Payroll]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: runId
|
|
||||||
required: true
|
|
||||||
schema: { $ref: '#/components/schemas/Ulid' }
|
|
||||||
responses:
|
|
||||||
'200': { description: Результат сверки }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
/imports/owner-workbook:
|
/imports/owner-workbook:
|
||||||
post:
|
post:
|
||||||
summary: Идемпотентно импортировать локально разобранную книгу владельца
|
summary: Идемпотентно импортировать локально разобранную книгу владельца
|
||||||
|
|
@ -1067,9 +914,6 @@ paths:
|
||||||
client_action: { type: string, enum: [FETCH_LATEST_AND_RETRY] }
|
client_action: { type: string, enum: [FETCH_LATEST_AND_RETRY] }
|
||||||
- { type: 'null' }
|
- { type: 'null' }
|
||||||
error_code:
|
error_code:
|
||||||
description: >
|
|
||||||
Код ошибки, если status = rejected.
|
|
||||||
Команды `upsert_work_session` и `upsert_timesheet_entry` отклоняются с кодом COMMAND_NOT_SUPPORTED.
|
|
||||||
oneOf:
|
oneOf:
|
||||||
- { type: string }
|
- { type: string }
|
||||||
- { type: 'null' }
|
- { type: 'null' }
|
||||||
|
|
@ -1110,20 +954,6 @@ paths:
|
||||||
'400': { $ref: '#/components/responses/BadRequest' }
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
'401': { description: Недействительный токен устройства }
|
'401': { description: Недействительный токен устройства }
|
||||||
'403': { description: Устройство отозвано или заблокировано }
|
'403': { description: Устройство отозвано или заблокировано }
|
||||||
/terminal/employees/{employeeId}/enrollment:
|
|
||||||
get:
|
|
||||||
summary: Проверить статус биометрической регистрации для терминала
|
|
||||||
operationId: getTerminalEmployeeEnrollment
|
|
||||||
tags: [Terminal]
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: employeeId
|
|
||||||
required: true
|
|
||||||
schema: { $ref: '#/components/schemas/Ulid' }
|
|
||||||
responses:
|
|
||||||
'200': { description: Статус регистрации }
|
|
||||||
'403': { description: Недостаточно прав }
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ services:
|
||||||
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
|
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
|
||||||
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
|
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
|
||||||
PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-}
|
PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-}
|
||||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID}
|
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:-01J00000000000000000000000}
|
||||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
|
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
|
||||||
ports: ['8080:8080']
|
ports: ['8080:8080']
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# The certificate contains only a public key. Keep its private key off the server.
|
|
||||||
BACKUP_DIRECTORY=/var/backups/krona-staff
|
|
||||||
BACKUP_RECIPIENT_CERT=/etc/krona-staff/backup-recipient.crt
|
|
||||||
# This-PC backups are collected through scripts/pull-pilot-backups.sh.
|
|
||||||
BACKUP_TRANSFER_MODE=external-pull
|
|
||||||
# Needed only for BACKUP_TRANSFER_MODE=push. For this-PC storage use pull script.
|
|
||||||
BACKUP_REMOTE=
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
# Test pilot on the owner's home server; no private key is stored here.
|
|
||||||
BACKUP_DIRECTORY=/home/ochenstarik/krona-staff-pilot/backups
|
|
||||||
BACKUP_RECIPIENT_CERT=/home/ochenstarik/krona-staff-pilot/deploy/pilot/secrets/backup-recipient.crt
|
|
||||||
BACKUP_TRANSFER_MODE=external-pull
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
#!/usr/bin/env sh
|
|
||||||
# Encrypt before writing to disk; keep the private recipient key off this host.
|
|
||||||
set -eu
|
|
||||||
umask 077
|
|
||||||
|
|
||||||
: "${BACKUP_DIRECTORY:?Set a dedicated absolute backup directory}"
|
|
||||||
: "${BACKUP_RECIPIENT_CERT:?Set the public recipient certificate path}"
|
|
||||||
BACKUP_TRANSFER_MODE="${BACKUP_TRANSFER_MODE:-push}"
|
|
||||||
case "$BACKUP_DIRECTORY" in /*) ;; *) echo 'Backup directory must be absolute' >&2; exit 1 ;; esac
|
|
||||||
BACKUP_DIRECTORY=$(realpath -m -- "$BACKUP_DIRECTORY")
|
|
||||||
case "$BACKUP_DIRECTORY" in /|/home|/tmp|/var|/usr|/etc|/opt|/srv|/root|/mnt|/media) echo 'Use a dedicated backup directory' >&2; exit 1 ;; esac
|
|
||||||
case "$BACKUP_TRANSFER_MODE" in
|
|
||||||
push)
|
|
||||||
: "${BACKUP_REMOTE:?Set an off-host scp destination, for example backup@host:/backups/staff/}"
|
|
||||||
case "$BACKUP_REMOTE" in -*|*\ *|*\;*|*\`*|*\$*) echo 'Invalid backup destination' >&2; exit 1 ;; esac
|
|
||||||
case "$BACKUP_REMOTE" in *:/*/) ;; *) echo 'Off-host destination must be host:/absolute/directory/' >&2; exit 1 ;; esac
|
|
||||||
;;
|
|
||||||
external-pull) ;;
|
|
||||||
*) echo 'BACKUP_TRANSFER_MODE must be push or external-pull' >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
test -r "$BACKUP_RECIPIENT_CERT"
|
|
||||||
openssl x509 -in "$BACKUP_RECIPIENT_CERT" -noout -checkend 0 >/dev/null
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
mkdir -p "$BACKUP_DIRECTORY"
|
|
||||||
stamp=$(date -u '+%Y%m%dT%H%M%SZ')
|
|
||||||
name="krona-staff-$stamp-$$.dump.cms"
|
|
||||||
output="$BACKUP_DIRECTORY/$name"
|
|
||||||
scratch=$(mktemp -d "$BACKUP_DIRECTORY/.backup-XXXXXX")
|
|
||||||
producer=''
|
|
||||||
cleanup() {
|
|
||||||
if [ -n "$producer" ]; then kill "$producer" 2>/dev/null || true; fi
|
|
||||||
rm -f -- "$scratch/dump.pipe" "$scratch/encrypted.partial"
|
|
||||||
rmdir -- "$scratch"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT HUP INT TERM
|
|
||||||
mkfifo "$scratch/dump.pipe"
|
|
||||||
docker compose exec -T postgres sh -eu -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$scratch/dump.pipe" &
|
|
||||||
producer=$!
|
|
||||||
openssl cms -encrypt -binary -aes-256-cbc -outform DER -stream \
|
|
||||||
-in "$scratch/dump.pipe" -out "$scratch/encrypted.partial" "$BACKUP_RECIPIENT_CERT"
|
|
||||||
wait "$producer"
|
|
||||||
producer=''
|
|
||||||
test -s "$scratch/encrypted.partial"
|
|
||||||
mv -- "$scratch/encrypted.partial" "$output"
|
|
||||||
(cd "$BACKUP_DIRECTORY" && sha256sum "$name" > "$name.sha256")
|
|
||||||
if [ "$BACKUP_TRANSFER_MODE" = push ]; then
|
|
||||||
scp -B "$output" "$output.sha256" "$BACKUP_REMOTE"
|
|
||||||
copy_status=completed
|
|
||||||
else
|
|
||||||
copy_status=pending_pull
|
|
||||||
fi
|
|
||||||
printf 'backup=%s\nchecksum=%s\noff_host_copy=%s\n' "$output" "$output.sha256" "$copy_status"
|
|
||||||
# This is an encrypted backup, not evidence of a successful restore drill.
|
|
||||||
|
|
@ -5,23 +5,23 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: development
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
STAFF_RUNTIME_MODE: standalone
|
STAFF_RUNTIME_MODE: standalone
|
||||||
STANDALONE_ACCESS: employee
|
STANDALONE_ACCESS: employee
|
||||||
STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN:?STANDALONE_MANAGER_TOKEN is required}
|
STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN}
|
||||||
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN:?STANDALONE_TERMINAL_TOKEN is required}
|
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN}
|
||||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?STANDALONE_TENANT_ID is required}
|
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
|
||||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:?STANDALONE_ACTOR_ID is required}
|
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
|
||||||
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID:?STANDALONE_TERMINAL_ACTOR_ID is required}
|
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:?VAPID_PUBLIC_KEY is required}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:?VAPID_PRIVATE_KEY is required}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT:?VAPID_SUBJECT is required}
|
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||||
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}@postgres:5432/krona_staff
|
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
|
||||||
HTTPS_PFX_PATH: /run/secrets/staff.pfx
|
HTTPS_PFX_PATH: /run/secrets/staff.pfx
|
||||||
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE:?HTTPS_PFX_PASSPHRASE is required}
|
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}
|
||||||
ports:
|
ports:
|
||||||
- '192.168.1.81:8453:8080'
|
- '192.168.1.81:8453:8080'
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -48,8 +48,8 @@ services:
|
||||||
context: ../..
|
context: ../..
|
||||||
target: build
|
target: build
|
||||||
environment:
|
environment:
|
||||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}@postgres:5432/krona_staff
|
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
|
||||||
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}
|
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
|
||||||
command: ['node', 'scripts/create-app-role.mjs']
|
command: ['node', 'scripts/create-app-role.mjs']
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
|
|
@ -60,7 +60,7 @@ services:
|
||||||
context: ../..
|
context: ../..
|
||||||
target: build
|
target: build
|
||||||
environment:
|
environment:
|
||||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}@postgres:5432/krona_staff
|
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
|
||||||
command: ['node', 'scripts/migrate.mjs', 'up']
|
command: ['node', 'scripts/migrate.mjs', 'up']
|
||||||
depends_on:
|
depends_on:
|
||||||
db-role: { condition: service_completed_successfully }
|
db-role: { condition: service_completed_successfully }
|
||||||
|
|
@ -71,7 +71,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: krona_staff
|
POSTGRES_DB: krona_staff
|
||||||
POSTGRES_USER: krona_migrator
|
POSTGRES_USER: krona_migrator
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}
|
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Encrypted Krona Staff PostgreSQL backup (user service)
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
WorkingDirectory=%h/krona-staff-pilot/deploy/pilot
|
|
||||||
EnvironmentFile=%h/krona-staff-pilot/deploy/pilot/backup.env
|
|
||||||
ExecStart=/bin/sh %h/krona-staff-pilot/deploy/pilot/backup.sh
|
|
||||||
UMask=0077
|
|
||||||
NoNewPrivileges=true
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Encrypted Krona Staff PostgreSQL backup
|
|
||||||
Requires=docker.service
|
|
||||||
After=docker.service network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/opt/krona-staff/deploy/pilot
|
|
||||||
EnvironmentFile=/etc/krona-staff/backup.env
|
|
||||||
ExecStart=/bin/sh /opt/krona-staff/deploy/pilot/backup.sh
|
|
||||||
UMask=0077
|
|
||||||
NoNewPrivileges=true
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Daily Krona Staff encrypted backup
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnCalendar=*-*-* 03:30:00
|
|
||||||
RandomizedDelaySec=600
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
|
|
@ -5,20 +5,7 @@ cd "$(dirname "$0")"
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p secrets
|
mkdir -p secrets
|
||||||
|
|
||||||
validate_tenant() {
|
|
||||||
: "${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID before setup}"
|
|
||||||
if ! printf '%s\n' "$STANDALONE_TENANT_ID" | LC_ALL=C grep -Eq '^[0-9A-HJKMNP-TV-Z]{26}$'; then
|
|
||||||
echo 'STANDALONE_TENANT_ID must be a 26-character ULID' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$STANDALONE_TENANT_ID" = '01J00000000000000000000000' ]; then
|
|
||||||
echo 'The development placeholder cannot be used for a pilot organization' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
validate_tenant
|
|
||||||
migration_password="$(openssl rand -hex 32)"
|
migration_password="$(openssl rand -hex 32)"
|
||||||
app_password="$(openssl rand -hex 32)"
|
app_password="$(openssl rand -hex 32)"
|
||||||
manager_token="$(openssl rand -hex 32)"
|
manager_token="$(openssl rand -hex 32)"
|
||||||
|
|
@ -30,7 +17,7 @@ if [ ! -f .env ]; then
|
||||||
printf 'STANDALONE_MANAGER_TOKEN=%s\n' "$manager_token"
|
printf 'STANDALONE_MANAGER_TOKEN=%s\n' "$manager_token"
|
||||||
printf 'STANDALONE_TERMINAL_TOKEN=%s\n' "$terminal_token"
|
printf 'STANDALONE_TERMINAL_TOKEN=%s\n' "$terminal_token"
|
||||||
printf 'HTTPS_PFX_PASSPHRASE=%s\n' "$pfx_passphrase"
|
printf 'HTTPS_PFX_PASSPHRASE=%s\n' "$pfx_passphrase"
|
||||||
printf 'STANDALONE_TENANT_ID=%s\n' "$STANDALONE_TENANT_ID"
|
printf 'STANDALONE_TENANT_ID=01J00000000000000000000000\n'
|
||||||
printf 'STANDALONE_ACTOR_ID=01J00000000000000000000001\n'
|
printf 'STANDALONE_ACTOR_ID=01J00000000000000000000001\n'
|
||||||
printf 'STANDALONE_TERMINAL_ACTOR_ID=01J00000000000000000000002\n'
|
printf 'STANDALONE_TERMINAL_ACTOR_ID=01J00000000000000000000002\n'
|
||||||
} > .env
|
} > .env
|
||||||
|
|
@ -38,9 +25,6 @@ if [ ! -f .env ]; then
|
||||||
printf '%s\n' "$terminal_token" > secrets/terminal.token
|
printf '%s\n' "$terminal_token" > secrets/terminal.token
|
||||||
fi
|
fi
|
||||||
|
|
||||||
. ./.env
|
|
||||||
validate_tenant
|
|
||||||
|
|
||||||
if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
|
if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$(cd ../.. && pwd):/app:ro" \
|
-v "$(cd ../.. && pwd):/app:ro" \
|
||||||
|
|
@ -50,7 +34,6 @@ if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
. ./.env
|
. ./.env
|
||||||
validate_tenant
|
|
||||||
if [ ! -f secrets/staff.pfx ]; then
|
if [ ! -f secrets/staff.pfx ]; then
|
||||||
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
|
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
|
||||||
-keyout secrets/staff.key -out secrets/staff.crt \
|
-keyout secrets/staff.key -out secrets/staff.crt \
|
||||||
|
|
|
||||||
|
|
@ -7,101 +7,3 @@ Restore runbook: остановить записи, проверить checksum/
|
||||||
Тест восстановления обязателен по расписанию в изолированной среде. До принятия RPO/RTO и retention документ является архитектурным шаблоном, а не обещанием показателей.
|
Тест восстановления обязателен по расписанию в изолированной среде. До принятия RPO/RTO и retention документ является архитектурным шаблоном, а не обещанием показателей.
|
||||||
|
|
||||||
Tenant export/import использует versioned archive, ULID, schema version, object references и checksum. Import идемпотентен и не может перезаписать другой tenant.
|
Tenant export/import использует versioned archive, ULID, schema version, object references и checksum. Import идемпотентен и не может перезаписать другой tenant.
|
||||||
|
|
||||||
## Pilot implementation — K18
|
|
||||||
|
|
||||||
Status: implementation in progress. A pilot restore drill succeeded on 2026-09-26;
|
|
||||||
the remaining schedule, retention and migration gate are listed below. Do not
|
|
||||||
use the presence of an encrypted file alone as permission to migrate tenant data.
|
|
||||||
|
|
||||||
## Storage and keys
|
|
||||||
|
|
||||||
`deploy/pilot/backup.sh` streams `pg_dump -Fc` through OpenSSL CMS encryption.
|
|
||||||
Only the public recipient certificate belongs on the server. Keep the private
|
|
||||||
recipient key in separate, access-controlled storage outside that server, with a
|
|
||||||
recovery copy. Loss of this key makes backups unrecoverable.
|
|
||||||
|
|
||||||
Configure the variables in `deploy/pilot/backup.env.example` using an operator-owned
|
|
||||||
environment file, never a committed secrets file. For the current test setup, use
|
|
||||||
`BACKUP_TRANSFER_MODE=external-pull` and schedule `scripts/pull-pilot-backups.sh`
|
|
||||||
on this PC with `PILOT_SSH_TARGET`, `PILOT_REMOTE_BACKUP_DIRECTORY` and
|
|
||||||
`BACKUP_LOCAL_DIRECTORY`. The PC initiates SSH, so it does not need an SSH server.
|
|
||||||
Configure non-interactive SSH and verify the server's host key out of band. Do not
|
|
||||||
disable host-key verification. A SHA-256 sidecar detects transfer corruption;
|
|
||||||
it is not a digital signature. Other deployments may use push mode with an
|
|
||||||
off-host SCP destination.
|
|
||||||
|
|
||||||
The PC pull script keeps 90 days of local copies by default. Set
|
|
||||||
`BACKUP_LOCAL_RETENTION_DAYS` to an integer from 1 to 36500 to change this period.
|
|
||||||
After a successful pull and freshness check, it removes only direct child files with
|
|
||||||
the generated `krona-staff-<UTC timestamp>-<pid>.dump.cms` name whose one-line
|
|
||||||
SHA-256 sidecar names that same file and matches its bytes. It removes the matching
|
|
||||||
`.sha256` sidecar with the encrypted file. Unverifiable pairs and symlinks are
|
|
||||||
preserved, as are unrelated files and directories. This policy applies to PC copies;
|
|
||||||
it does not prune the server's backup directory.
|
|
||||||
|
|
||||||
The server script fails if dumping or encryption fails. In push mode it also fails
|
|
||||||
if off-host transfer fails. It does not write an unencrypted dump to disk. In
|
|
||||||
external-pull mode a successful server run reports `pending_pull`, never an
|
|
||||||
off-host success. The PC pull script verifies every transferred encrypted file
|
|
||||||
against its checksum and exits nonzero on transfer or integrity failure.
|
|
||||||
|
|
||||||
## Scheduling
|
|
||||||
|
|
||||||
The service and timer in `deploy/pilot/` are templates. Adjust their working and
|
|
||||||
configuration paths to the actual installation before enabling them. The timer
|
|
||||||
runs daily at 03:30 in the server's timezone, with up to ten minutes of jitter.
|
|
||||||
Monitor both the server service and the PC pull task exit statuses. Verify that
|
|
||||||
the PC directory contains a recent copy; the server-only encrypted file is not
|
|
||||||
evidence of off-host protection.
|
|
||||||
|
|
||||||
Do not enable the schedule until the destination, recovery-key custody, server-side
|
|
||||||
retention policy, and a successful restore drill have been agreed and tested.
|
|
||||||
|
|
||||||
## Required restore drill before tenant migration
|
|
||||||
|
|
||||||
1. Produce a consistent dump, encrypt it, and retrieve the off-host copy.
|
|
||||||
2. Verify the encrypted file's SHA-256 checksum at the destination.
|
|
||||||
3. Decrypt with the off-host key. Restore into a new disposable PostgreSQL 17
|
|
||||||
instance, with no connection to the pilot network and no exposed database port.
|
|
||||||
Never run a test provisioning script against the pilot cluster: it may alter
|
|
||||||
roles or drop databases.
|
|
||||||
4. Restore required roles and grants, schema, data, forced RLS, constraints and
|
|
||||||
migration history. A restore using `--no-owner --no-acl` alone is not enough
|
|
||||||
evidence that production privileges can be recovered.
|
|
||||||
5. Compare per-table and per-tenant row counts and payroll ledger totals per
|
|
||||||
currency against a manifest from the same consistent snapshot. Check foreign
|
|
||||||
keys, forced RLS, and isolation using a non-superuser application role.
|
|
||||||
6. Run the tenant migration on that restored copy and verify that only the tenant
|
|
||||||
changes: row counts and ledger totals are preserved, with one additional audit
|
|
||||||
record for the migration.
|
|
||||||
7. Save a report containing timestamp, source identity, backup checksum, PostgreSQL
|
|
||||||
version, commands and exit statuses, before/after counts, totals and isolation
|
|
||||||
result. Do not include passwords, tokens, employee names, or biometric data.
|
|
||||||
8. Only after a successful drill and an agreed maintenance window may the pilot
|
|
||||||
application be stopped and the operation repeated on a fresh backup.
|
|
||||||
|
|
||||||
## Outstanding acceptance items
|
|
||||||
|
|
||||||
- Off-host storage and retention policy selected and configured.
|
|
||||||
- Restore automation and repeatable reporting for later backups.
|
|
||||||
- A fail-closed backup gate before migrations in `setup.sh`.
|
|
||||||
|
|
||||||
Until these items are complete, K18 remains open and pilot tenant migration is blocked.
|
|
||||||
|
|
||||||
## Pilot restore drill — 2026-09-26
|
|
||||||
|
|
||||||
An encrypted `pg_dump -Fc` from `krona-staff-pilot-postgres-1` was pulled to the
|
|
||||||
owner's PC. Its SHA-256 was
|
|
||||||
`2fc680ae3a6ce63a27b53209da9b506bcee86b76d8bba9a6a06de24bac1dee47`.
|
|
||||||
It was decrypted on the PC and streamed through SSH into a new PostgreSQL 17
|
|
||||||
container with `network=none`, no host ports, and temporary storage. The restore
|
|
||||||
command returned 0 with ownership and ACLs restored. The reports produced by
|
|
||||||
`scripts/restore-drill-report.sql` matched for every tenant table: 51 employees,
|
|
||||||
3,830 ledger entries, 3,353 work items, 2,894 daily metrics and 49 audit rows;
|
|
||||||
the other nonempty tables matched too. All 27 tenant tables had ENABLE and FORCE
|
|
||||||
RLS. The RUB ledger sum matched at 717,829,954 minor units. A nonsuperuser,
|
|
||||||
non-BYPASSRLS application role saw 51 employees under the source tenant and zero
|
|
||||||
under the target. The restore report and encrypted backup are stored on the PC
|
|
||||||
in `C:/Users/trush/Documents/KronaStaffBackups/`. This was a restore drill, not
|
|
||||||
yet a tenant migration or a check that the backup schedule runs unattended.
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
# Развёртывание
|
# Развёртывание
|
||||||
|
|
||||||
После копирования `.env.example` обязательно заполните `STANDALONE_TENANT_ID` идентификатором организации (ULID) из Krona Business Platform. Значения по умолчанию нет; прежняя заглушка `01J00000000000000000000000` не подходит для Compose или пилота. Для первого запуска `deploy/pilot/setup.sh` передайте этот ID через переменную окружения: установщик проверит его и сохранит в своём `.env`. При повторном запуске проверяется существующий `.env`. Для уже заполненного пилота нельзя просто заменить ID: сначала нужна резервная копия, проверка восстановления и перенос tenant-данных.
|
|
||||||
|
|
||||||
Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов.
|
Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов.
|
||||||
|
|
||||||
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.
|
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.
|
||||||
|
|
|
||||||
|
|
@ -214,10 +214,6 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
||||||
2. Сервер извлекает ранее зафиксированный результат (`status`, `result`, `conflict`, `error_code`, `error_message`) и возвращает его клиенту.
|
2. Сервер извлекает ранее зафиксированный результат (`status`, `result`, `conflict`, `error_code`, `error_message`) и возвращает его клиенту.
|
||||||
3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry).
|
3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry).
|
||||||
|
|
||||||
### 4.3. Запрещённые команды
|
|
||||||
|
|
||||||
Прямое создание или изменение смен (`upsert_work_session`) и табелей (`upsert_timesheet_entry`) с устройств строго запрещено для предотвращения несанкционированных начислений. При получении таких команд сервер отклоняет их со статусом `rejected` и кодом ошибки `COMMAND_NOT_SUPPORTED` (без записи в базу данных).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Обнаружение конфликтов
|
## 5. Обнаружение конфликтов
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
# Руководство по развёртыванию и чек-лист боевого тестирования Krona Staff
|
|
||||||
|
|
||||||
## 1. Назначение и архитектурные инварианты
|
|
||||||
|
|
||||||
Krona Staff — автономно развёртываемый блок Krona Business Platform, отвечающий за учёт рабочего времени, терминалы посещаемости и управленческий расчёт зарплаты.
|
|
||||||
|
|
||||||
### Ключевые инварианты (AGENTS.md)
|
|
||||||
|
|
||||||
1. **Разделение доменов**: Core владеет Tenant, User, Role, SSO, Platform Settings, Files, Notifications. Staff владеет Employee и HR-доменом (`User != Employee`).
|
|
||||||
2. **Изоляция арендаторов**: Все таблицы содержат `tenant_id` (ULID 26 символов). В PostgreSQL включен и принудительно активирован Row-Level Security (`FORCE ROW LEVEL SECURITY`). Запросы выполняются через tenant-scoped транзакции с выставлением `app.tenant_id`.
|
|
||||||
3. **Разделение ролей БД**:
|
|
||||||
- `krona_migrator` — владелец схемы и таблиц, выполняет миграции DDL, `NOSUPERUSER NOBYPASSRLS`.
|
|
||||||
- `krona_staff_app` — runtime-пользователь приложения с минимальными DML правами (SELECT, INSERT, UPDATE, DELETE на прикладные таблицы; нет прав на `schema_migrations`, нет прав DDL).
|
|
||||||
4. **Типы данных**:
|
|
||||||
- Идентификаторы — ULID (`VARCHAR(26)`).
|
|
||||||
- Денежные суммы — `BIGINT` в минимальных единицах (копейках/minor units) + ISO 4217 код валюты (например, `'RUB'`).
|
|
||||||
- Временные метки — UTC (`TIMESTAMPTZ`).
|
|
||||||
5. **Надёжность**: Транзакционный Outbox, идемпотентность по `Idempotency-Key`, CSRF-защита сессионных cookie (`Origin`/`Sec-Fetch-Site`), Rate-limiting ключей входа.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Развёртывание и запуск
|
|
||||||
|
|
||||||
### 2.1. Системные требования
|
|
||||||
|
|
||||||
- **ОС**: Linux (Ubuntu 22.04+/Debian 12+) или Windows Server 2022+ / Windows 11 (для сборки терминала).
|
|
||||||
- **Среда выполнения**: Node.js 22 LTS, npm 10+.
|
|
||||||
- **База данных**: PostgreSQL 17 (поддерживается 17.0+).
|
|
||||||
- **Сеть**: Порт 8080 (HTTP) или 8453 (HTTPS пилот).
|
|
||||||
|
|
||||||
### 2.2. Запуск через Docker Compose (рекомендуемый пилот)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Перейдите в каталог deploy/pilot
|
|
||||||
cd deploy/pilot
|
|
||||||
|
|
||||||
# 2. Задайте ULID организации из Krona Business Platform
|
|
||||||
export STANDALONE_TENANT_ID="01J0000000000000000000000A"
|
|
||||||
|
|
||||||
# 3. Выполните скрипт первичной инициализации:
|
|
||||||
# - Сгенерирует стойкие пароли БД, токены менеджера и терминала в secrets/
|
|
||||||
# - Сгенерирует ключи Web Push (VAPID)
|
|
||||||
# - Создаст самоподписанный TLS-сертификат для терминала
|
|
||||||
./setup.sh
|
|
||||||
|
|
||||||
# 4. Запустите стек (PostgreSQL + мигратор + app)
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# 5. Проверьте запуск контейнеров и миграций
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs -f app
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3. Запуск без Docker (Bare-Metal / Dev стенд)
|
|
||||||
|
|
||||||
1. **Создайте базу данных и роли в PostgreSQL**:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Под пользователем postgres (DBA):
|
|
||||||
CREATE ROLE krona_migrator LOGIN PASSWORD 'migrator_secure_password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
|
||||||
CREATE ROLE krona_staff_app LOGIN PASSWORD 'app_secure_password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
|
||||||
|
|
||||||
CREATE DATABASE krona_staff OWNER krona_migrator ENCODING 'UTF8';
|
|
||||||
\c krona_staff
|
|
||||||
ALTER SCHEMA public OWNER TO krona_migrator;
|
|
||||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
|
||||||
REVOKE ALL ON DATABASE krona_staff FROM PUBLIC;
|
|
||||||
GRANT CONNECT ON DATABASE krona_staff TO krona_staff_app;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Настройте переменные окружения (`.env`)**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Укажите в `.env`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
PORT=8080
|
|
||||||
NODE_ENV=production
|
|
||||||
STANDALONE_TENANT_ID=01J0000000000000000000000A
|
|
||||||
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
|
||||||
STANDALONE_MANAGER_TOKEN=your_secure_manager_token_hex_or_ulid
|
|
||||||
STANDALONE_TERMINAL_TOKEN=your_secure_terminal_token_hex_or_ulid
|
|
||||||
DATABASE_URL=postgres://krona_staff_app:app_secure_password@127.0.0.1:5432/krona_staff
|
|
||||||
DATABASE_MIGRATION_URL=postgres://krona_migrator:migrator_secure_password@127.0.0.1:5432/krona_staff
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Сгенерируйте VAPID ключи**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node scripts/generate-vapid-keys.mjs >> .env
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Примените миграции**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run db:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Соберите и запустите приложение**:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Чек-лист ручного боевого тестирования владельцем
|
|
||||||
|
|
||||||
Каждый шаг должен быть выполнен последовательно. Ожидаемый результат сверяется с фактическим.
|
|
||||||
|
|
||||||
| № | Шаг / Сценарий | Действие / HTTP-запрос | Ожидаемый результат | Статус |
|
|
||||||
| ------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | :----: |
|
|
||||||
| **1** | **Системные проверки (Health)** | `GET /api/v1/staff/live`<br>`GET /api/v1/staff/ready`<br>`GET /api/v1/staff/version` | HTTP 200 на всех эндпоинтах. `ready` подтверждает соединение с БД под ролью `krona_staff_app`. `version` возвращает `runtime: standalone` и semver. | [ ] |
|
|
||||||
| **2** | **Контракт OpenAPI** | `GET /api/v1/staff/openapi.json` | HTTP 200, возвращается валидная спецификация OpenAPI 3.1 со всеми роутами модуля. | [ ] |
|
|
||||||
| **3** | **Защита доступа (401)** | `GET /api/v1/staff/portal` без заголовка Authorization | HTTP 401 Unauthorized. Ошибки с деталями SQL отсутствуют. | [ ] |
|
|
||||||
| **4** | **Защита от CSRF** | `POST /api/v1/staff/portal/employees` с cookie сессии, но без заголовка `Origin` или с чужим Origin | HTTP 403 Forbidden (CSRF token missing/invalid). | [ ] |
|
|
||||||
| **5** | **Защита от перебора паролей (429)** | 35 последовательных запросов с неверным токеном/ключом | После исчерпания лимита возвращается HTTP 429 Too Many Requests с заголовком `Retry-After`. | [ ] |
|
|
||||||
| **6** | **Создание сотрудника** | `POST /api/v1/staff/employees`<br>Headers: `Authorization: Bearer <MANAGER_TOKEN>`<br>Body: `{"personnel_number": "EMP-001", "display_name": "Иванов Иван", "profession_code": "cook"}` | HTTP 201 Created. Создан сотрудник, присвоен ULID, запись зафиксирована в `staff_employees`. | [ ] |
|
|
||||||
| **7** | **Проверка справочника профессий** | `GET /api/v1/staff/professions` | HTTP 200, список активных профессий (`cook`, `cashier`, `barista`, `waiter` и др.). | [ ] |
|
|
||||||
| **8** | **Назначение тарифной ставки сотруднику** | `POST /api/v1/staff/payroll/assignments`<br>Body: привязка правила (почасовая ставка 350 руб/час = 35000 minor units). | HTTP 201 Created. Версионированное правило сохранено в `staff_pay_assignments`. | [ ] |
|
|
||||||
| **9** | **Отметка прихода через терминал** | `POST /api/v1/staff/attendance/events`<br>Headers: `Authorization: Bearer <TERMINAL_TOKEN>`<br>Body: `{"event_id": "<ULID>", "employee_id": "<EMP_ID>", "event_type": "check_in", "occurred_at": "<NOW_ISO>"}` | HTTP 201 Created. Событие записано в `staff_attendance_events`. Отправлено событие в outbox. | [ ] |
|
|
||||||
| **10** | **Идемпотентность отметки** | Повтор запроса шага 9 с тем же `event_id` и `source_event_id` | HTTP 200 OK (или 201), дубликат не создаётся, количество записей не удваивается. | [ ] |
|
|
||||||
| **11** | **Отметка ухода через терминал** | `POST /api/v1/staff/attendance/events` с `event_type: "check_out"` спустя 8 часов | HTTP 201 Created. Формируется завершённая рабочая смена. | [ ] |
|
|
||||||
| **12** | **Подтверждение смены менеджером** | `POST /api/v1/staff/work-sessions/:id/confirm` | HTTP 200 OK. Смена переходит в статус `confirmed`, часы готовы к начислению. | [ ] |
|
|
||||||
| **13** | **Авторасчёт ведомости зарплаты** | `POST /api/v1/staff/payroll/runs/auto-calc`<br>Body: период с 1 по 15 число месяца. | HTTP 201 Created. Начислено: 8 ч × 350 руб = 2800 руб (280000 minor units). Сохранена расшифровка формулы расчёта. | [ ] |
|
|
||||||
| **14** | **Согласование ведомости бухгалтером** | `POST /api/v1/staff/payroll/batches/:id/approve` | HTTP 200 OK. Статус пакета переходит в `approved`. | [ ] |
|
|
||||||
| **15** | **Подписание ведомости директором** | `POST /api/v1/staff/payroll/batches/:id/sign`<br>Body: `{"signature": "director_sign_key", "note": "Выплата за первую половину месяца"}` | HTTP 200 OK. Пакет подписан. Созданы строки начислений в `staff_payroll_ledger` с полем `note` (без ошибки 500!). | [ ] |
|
|
||||||
| **16** | **Проверка реестра выплат (Ledger)** | `GET /api/v1/staff/payroll/ledger` | HTTP 200 OK. Отображаются начисления и выплаты, сальдо корректно сходится. | [ ] |
|
|
||||||
| **17** | **Web Push уведомления сотруднику** | `POST /api/v1/staff/portal/push-subscriptions` с валидным FCM/W3C endpoint. | HTTP 201 Created. Подписка сохранена. При наступлении события формируется delivery без ложных блокировок хоста. | [ ] |
|
|
||||||
| **18** | **Уведомления директору без дублей** | `GET /api/v1/staff/notifications/unread` под учётной записью директора | HTTP 200 OK. Каждому директору отдаются ровно его уведомления без дублирования на каждого получателя. | [ ] |
|
|
||||||
| **19** | **Офлайн-синхронизация: регистрация терминала** | `POST /api/v1/staff/sync/devices/register` с токеном менеджера | HTTP 201 Created. Возвращается токен устройства `kstaff_dev_...`. Токен сохраняется в терминале. | [ ] |
|
|
||||||
| **20** | **Офлайн-синхронизация: начальный Pull (C0)** | `GET /api/v1/staff/sync/pull` с токеном устройства (без курсора) | HTTP 200 OK. Возвращаются справочники (сотрудники, оргструктура, тарифы) и курсор `cursor_ulid`. | [ ] |
|
|
||||||
| **21** | **Офлайн-синхронизация: пакет команд Push** | `POST /api/v1/staff/sync/push` с массивом офлайн-отметок посещаемости | HTTP 200 OK. Все корректные команды применены, битые команды получают понятную ошибку, статус транзакции не ломает весь пакет. | [ ] |
|
|
||||||
| **22** | **Отзыв прав уволенного сотрудника** | Перевод сотрудника в статус `fired` / деактивация доступа в `staff_access_grants` | Попытка входа уволенного сотрудника немедленно возвращает HTTP 401 Unauthorized. | [ ] |
|
|
||||||
| **23** | **Проверка утилиты миграции арендатора (Rehome)** | `node scripts/rehome-tenant.mjs --dry-run --source <OLD_ULID> --target <NEW_ULID>` | Утилита производит корректное сравнение количества строк по всем таблицам (`Number`) без сбоев типов и ошибок внешних ключей. | [ ] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Диагностика и устранение неполадок
|
|
||||||
|
|
||||||
1. **Ошибка `401 Unauthorized` при запросе от терминала**:
|
|
||||||
- Проверьте, передан ли заголовок `Authorization: Bearer <STANDALONE_TERMINAL_TOKEN>` или токен зарегистрированного устройства.
|
|
||||||
2. **Ошибка `403 Forbidden` при запросах из веб-интерфейса**:
|
|
||||||
- Убедитесь, что запрос отправляется с доверенного Origin (`localhost`, `127.0.0.1` или IP пилота). Сессионные мутирующие запросы требуют валидный Origin.
|
|
||||||
3. **Проверка логов транзакций и RLS**:
|
|
||||||
```bash
|
|
||||||
# Просмотр логов контейнера
|
|
||||||
docker compose logs -f app
|
|
||||||
```
|
|
||||||
4. **Проверка состояния миграций БД**:
|
|
||||||
```bash
|
|
||||||
node scripts/check-migrations.mjs
|
|
||||||
```
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
# Перенос арендатора Staff
|
|
||||||
|
|
||||||
`scripts/rehome-tenant.mjs` переносит все строки из одного tenant ID в другой в схеме `public`. Скрипт рассчитан на остановленное приложение и запускается только привилегированной ролью БД, способной обходить RLS и изменять таблицы. Строки пароля или подключения в отчёты не выводятся.
|
|
||||||
|
|
||||||
CLI по умолчанию выполняет только read-only dry-run; `--dry-run` можно указать явно. Совместимые имена `--from`/`--to` и `--json` принимаются для rehearsal-обвязок; вывод всегда JSON. Сначала на восстановленной копии базы выполните dry-run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:DATABASE_ADMIN_URL = 'postgresql://…'
|
|
||||||
node scripts/rehome-tenant.mjs --from <ULID> --to <ULID> --dry-run --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Для совместимости со старыми локальными инструкциями также принимаются `DATABASE_REHOME_URL` и `DATABASE_MIGRATION_URL` как fallback. `--apply` по-прежнему требует явный флаг, actor и проверенный `--backup-evidence`; aliases и default dry-run не разрешают запись сами по себе.
|
|
||||||
|
|
||||||
Dry-run открывает транзакцию `READ ONLY` и выводит только идентификаторы переноса и количество строк по таблицам. Он не блокирует записи; поэтому результат является снимком для планирования, а не гарантией актуальности на момент apply. На копии и в согласованное окно с остановленным приложением повторите dry-run непосредственно перед переносом.
|
|
||||||
|
|
||||||
## Обязательное подтверждение восстановления
|
|
||||||
|
|
||||||
Apply требует `--backup-evidence <путь>` и читает JSON отчёт. Само имя файла и существование файла не считаются доказательством. JSON должен содержать сведения о зашифрованном off-host `pg_dump -Fc` и успешном restore drill именно этого backup:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"backup": {
|
|
||||||
"format": "pg_dump -Fc",
|
|
||||||
"encrypted": true,
|
|
||||||
"offHost": true,
|
|
||||||
"sha256": "<64 hex characters>",
|
|
||||||
"createdAt": "2026-09-26T08:00:00Z"
|
|
||||||
},
|
|
||||||
"restoreDrill": {
|
|
||||||
"status": "passed",
|
|
||||||
"backupSha256": "<same 64 hex characters>",
|
|
||||||
"restoredAt": "2026-09-26T08:30:00Z",
|
|
||||||
"verifiedAt": "2026-09-26T08:45:00Z",
|
|
||||||
"isolatedDatabase": "krona_staff_restore_check"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот файл удостоверяет результаты, предоставленные оператором; скрипт не проверяет доступ к хранилищу, шифрование или пригодность backup самостоятельно. Сохраните исходный backup, хеш и протокол restore drill вне сервера приложения.
|
|
||||||
|
|
||||||
## Apply
|
|
||||||
|
|
||||||
Проверьте dry-run и backup evidence, остановите приложение, затем запускайте в согласованное окно. `--actor` — ULID уполномоченного пользователя Core, не пароль или секрет:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
node scripts/rehome-tenant.mjs --apply `
|
|
||||||
--source <ULID> --target <ULID> --actor <CORE_USER_ULID> `
|
|
||||||
--backup-evidence .\evidence\restore-drill.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply использует одну транзакцию, сериализуемый уровень изоляции, advisory lock и детерминированные `ACCESS EXCLUSIVE` locks всех таблиц `public.staff_*`. Скрипт отказывает, если целевой tenant уже содержит строки, таблица лишена `tenant_id` или FORCE RLS, обнаружен неизвестный пользовательский trigger либо внешний FK, или роль БД не может безопасно выполнить перенос. Существующие FK сохраняются с их определениями и состоянием validation; если проверка после пересоздания не совпала, транзакция откатывается.
|
|
||||||
|
|
||||||
Отчёт содержит количества перенесённых строк и суммы `staff_payroll_ledger.amount_minor` отдельно по валюте. В `staff_audit_log` перенос сам создаёт ровно одну новую запись `tenant.rehomed` под целевым tenant. Поэтому число строк `staff_audit_log` после commit равно исходному числу строк источника плюс одна; по всем остальным таблицам количество равно исходному. Запись аудита включает from/to, исходные количества, суммы реестра, Core actor, correlation ID и SHA-256/время backup и проверки restore.
|
|
||||||
|
|
||||||
При ошибке stdout/stderr не содержит URL подключения, значений строк или текста серверной ошибки: транзакция откатывается, а в stderr печатается только безопасное сообщение и, если есть, пятисимвольный PostgreSQL SQLSTATE. После успешного commit отдельно проверьте `SELECT DISTINCT tenant_id` по каждой staff таблице, количества строк, сумму реестра по валюте и наличие записи `tenant.rehomed`.
|
|
||||||
|
|
||||||
## Интеграционный тест
|
|
||||||
|
|
||||||
Тест `tests/integration/rehome-tenant.test.ts` предназначен только для отдельной пустой PostgreSQL 17 базы, имя которой заканчивается на `_rehome_test`. Он требует `DATABASE_REHOME_TEST_URL` и явное `DATABASE_REHOME_TEST_CONFIRM=I_ACKNOWLEDGE_DISPOSABLE_PG17_DATABASE`, создаёт в ней роль приложения без пароля при необходимости и применяет миграции на чистой схеме. В конце тест очищает таблицы `public.staff_*`; проверка пустоты выполняется до вставки фикстур. Не указывайте production, pilot или общую базу.
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# Приёмка №2: Grok, Codex, Antigravity — 2026-09-26
|
|
||||||
|
|
||||||
Проверка независимая, на живой PostgreSQL 17.10, в отдельных рабочих копиях. Ветки исполнителей не менялись. Основная ветка — `f5ac179`.
|
|
||||||
|
|
||||||
## Итог
|
|
||||||
|
|
||||||
| Исполнитель | Ветка | Вердикт |
|
|
||||||
| ----------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Grok | `grok/e2e-harness` @ `ba59a48` | **Принять и влить первым.** Всё из TASK-GROK-2 сделано и подтверждено |
|
|
||||||
| Codex | `codex/fix-live-database` @ `b289dd6` | **K3 и K4 исправлены** (подтверждено мной и Grok). Влить после пересборки ветки форматирования |
|
|
||||||
| Codex | `codex/fix-format-baseline` @ `fd1ec3d` | **Переделать:** в коммит форматирования попало функциональное изменение Antigravity |
|
|
||||||
| Antigravity | `antigravity/fix-reject-device-shifts` @ `1df2019` | **Не принимать пока:** S1 исправлен, но интеграционный тест на реальной базе по-прежнему красный |
|
|
||||||
| Antigravity | `antigravity/fix-sync-c0-bootstrap` @ `8baad55` | **SYNC-C0 исправлен**, но тест падает в `afterAll`, и ветка конфликтует с предыдущей. См. дополнение ниже |
|
|
||||||
| Codex | `codex/fix-format-baseline` @ `513cdff` (перезалив) | **По-прежнему переделать:** синхронизация Antigravity осталась в коммите. См. дополнение ниже |
|
|
||||||
|
|
||||||
## Grok — принято
|
|
||||||
|
|
||||||
Проверено мной:
|
|
||||||
|
|
||||||
- **Windows.** `node scripts/test/run-e2e.mjs` запускается на Windows 11 **без правок**: 54 сценария, 45 красных (все — известные дефекты), 9 зелёных, проверка списка известных дефектов проходит. После прогона рабочая копия чистая — прогонные файлы в git больше не попадают.
|
|
||||||
- **Сопоставление ID.** Сделано по таблице аудита: сценарии называются двойными ID вида `K3/N2`, ключи `known-defects.json` переименованы так же.
|
|
||||||
- **Недостающие сценарии добавлены:** K6, K7, K8, K9, S9, A4, N12-DATE. Все красные, как и должно быть. K7 уже показывает потерю денег при импорте: итог 22 200 вместо 33 300 и 37 700.
|
|
||||||
- **Приёмка веток исполнителей подтверждена.** На ветке Codex `codex/fix-live-database` я сам прогнал стенд Grok и получил 41 красный и 13 зелёных — ровно как в его отчёте. Позеленели K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B.
|
|
||||||
- **Зона не нарушена**, все коммиты с объяснением причины.
|
|
||||||
|
|
||||||
Замечания на следующий круг:
|
|
||||||
|
|
||||||
- Windows-раннера нет, поэтому задача `windows` в CI выключена переменной. Это честно отмечено в отчёте.
|
|
||||||
- В приёмке веток запускались только сценарии `tests/e2e`, а интеграционные тесты самих исполнителей — нет. Из-за этого не пойман красный тест Antigravity (см. ниже).
|
|
||||||
|
|
||||||
## Codex
|
|
||||||
|
|
||||||
**`codex/fix-live-database`: K3 и K4 исправлены.**
|
|
||||||
|
|
||||||
- Подтверждение смены, расчёт и утверждение работают на реальной базе; создание сотрудника отвечает 201.
|
|
||||||
- Цепочка расчёта теперь доходит до денежных дефектов, и они подтверждаются по существу:
|
|
||||||
- K5 — одна смена начисляется дважды: 196 800 вместо 98 400;
|
|
||||||
- K6 — после отклонения оплаченной смены нет сторно.
|
|
||||||
|
|
||||||
Это следующий этап задания (этап 3), а не ошибка ветки.
|
|
||||||
|
|
||||||
- Остаток A4: `month=2026-13` по-прежнему отвечает 500 и возвращает код PostgreSQL.
|
|
||||||
|
|
||||||
**`codex/fix-format-baseline`: переделать.** Grok установил, что «стилевой» коммит `fd1ec3d` содержит функциональное изменение: после нормализации форматирования `src/domain/sync/service.ts` совпадает с исправлением Antigravity, а `tests/unit/sync.test.ts` — тоже. Два нарушения AGENTS.md сразу:
|
|
||||||
|
|
||||||
- смешение тем — поведение спрятано в коммите форматирования;
|
|
||||||
- правка чужой зоны: синхронизация принадлежит Antigravity.
|
|
||||||
|
|
||||||
Если влить обе ветки, изменение Antigravity придёт дважды, и одна из веток получит конфликт. Нужно пересобрать коммит форматирования **без** изменений в `src/domain/sync/**` и `tests/unit/sync.test.ts`. Исправление S1 должно прийти только из ветки Antigravity.
|
|
||||||
|
|
||||||
## Antigravity
|
|
||||||
|
|
||||||
**`antigravity/fix-reject-device-shifts` @ `1df2019`.**
|
|
||||||
|
|
||||||
- S1 исправлен: у Grok SYNC-A и SYNC-B зелёные.
|
|
||||||
- Сообщение коммита приведено к формату: короткий заголовок, причина в теле.
|
|
||||||
- Очистка теста переведена на роль мигратора.
|
|
||||||
|
|
||||||
**Но тест по-прежнему красный на реальной базе.** Теперь он падает раньше, на подготовке данных: `column "created_by" of relation "staff_employees" does not exist` (`tests/integration/sync-rejection.test.ts:81`). Оба теста файла пропускаются, файл целиком — FAIL. Значит, на настоящей базе тест снова не запускали. Пункт 1 задания не закрыт.
|
|
||||||
|
|
||||||
Как проверить у себя до пуша:
|
|
||||||
|
|
||||||
```text
|
|
||||||
DATABASE_TEST_URL=<роль приложения> DATABASE_MIGRATION_URL=<мигратор> npx vitest run tests/integration/sync-rejection.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Готово, когда: `Tests 2 passed`.
|
|
||||||
|
|
||||||
## Порядок влития
|
|
||||||
|
|
||||||
1. `grok/e2e-harness` — влить сейчас: только тесты и CI.
|
|
||||||
2. Codex пересобирает `codex/fix-format-baseline` без синхронизации, затем вливаются форматирование и `codex/fix-live-database`. Grok отдельным коммитом убирает K3/N2 и K4/N1 из `known-defects.json`.
|
|
||||||
3. Antigravity чинит тест, затем вливается его ветка. Grok убирает K13/SYNC-A и K13/SYNC-B.
|
|
||||||
|
|
||||||
## Новое: задача по пилоту от сессии платформы
|
|
||||||
|
|
||||||
Сессия «БП» по просьбе владельца и в режиме только чтения выяснила два факта.
|
|
||||||
|
|
||||||
1. Пилот на 192.168.1.81 пишет все данные под арендатором-заглушкой `01J00000000000000000000000`. Настоящий арендатор владельца в платформе — `01M2R6AC5652NPT3MG34DKN4ZP` («Сушигедза»).
|
|
||||||
2. Скрипта сопоставления standalone → platform, обещанного в `docs/PLATFORM-INTEGRATION.md`, нет.
|
|
||||||
|
|
||||||
В репозитории я подтвердил:
|
|
||||||
|
|
||||||
- заглушку записывает сам `deploy/pilot/setup.sh:20`;
|
|
||||||
- она же стоит значением по умолчанию в `compose.yaml:16` и в `.env.example:14`;
|
|
||||||
- в `scripts/` скрипта сопоставления нет.
|
|
||||||
|
|
||||||
Задание — [TASK-CODEX-2.md](TASK-CODEX-2.md).
|
|
||||||
|
|
||||||
Договор идентичности с платформой (модуль ждёт JWT, платформа шлёт неподписанные заголовки) исправляется на стороне платформы: пункты А-5, С-6, С-7 её плана. Этим занимается сессия «БП». Модулю — не менять `PlatformIdentityAdapter` под неподписанные заголовки.
|
|
||||||
|
|
||||||
## Дополнение: перезаливы после первой версии приёмки
|
|
||||||
|
|
||||||
**`antigravity/fix-sync-c0-bootstrap` @ `8baad55`** (от `f5ac179`).
|
|
||||||
|
|
||||||
- Статус точки при начальной загрузке теперь вычисляется из `is_active` и `deleted_at`. На стенде Grok SYNC-C0 зелёный, итог стенда — 44 красных, 10 зелёных.
|
|
||||||
- `tests/integration/sync-bootstrap.test.ts` на реальной базе: `Tests 2 passed`, но файл FAIL. Пул закрывается дважды: `app.close()` уже закрывает его через хук `onClose` (`src/app.ts:148`), а тест в `afterAll` (строка 96) закрывает его снова — `Called end on pool more than once`.
|
|
||||||
- Конфликтует с `antigravity/fix-reject-device-shifts` в `src/domain/sync/service.ts`: обе ветки созданы от одной основы.
|
|
||||||
|
|
||||||
**`codex/fix-format-baseline` @ `513cdff`** (перезалив от `f5ac179`).
|
|
||||||
|
|
||||||
- Я нормализовал prettier'ом версии из основы, из ветки Codex и из ветки Antigravity. `src/domain/sync/service.ts` и `tests/unit/sync.test.ts` у Codex **построчно совпадают** с веткой Antigravity и отличаются от основы на 255 и 139 строк. Исправление S1 по-прежнему в коммите форматирования. Пункт 1 TASK-CODEX-2 не выполнен.
|
|
||||||
- `codex/fix-live-database` перебазирован на него (`8e955b0`), поэтому тоже несёт эту копию.
|
|
||||||
|
|
||||||
**Конфликты между ветками** (`git merge-tree`):
|
|
||||||
|
|
||||||
| Пара | Результат |
|
|
||||||
| ------------------------ | ----------------------------------------------------- |
|
|
||||||
| AG reject + AG c0 | конфликт |
|
|
||||||
| Codex format + AG c0 | конфликт |
|
|
||||||
| Codex format + AG reject | чисто — потому что в Codex лежит копия исправления AG |
|
|
||||||
| Grok + AG c0 | чисто |
|
|
||||||
|
|
||||||
**Задания следующего круга:**
|
|
||||||
|
|
||||||
- [TASK-ANTIGRAVITY-3.md](TASK-ANTIGRAVITY-3.md) — починить оба теста, выстроить ветки в цепочку, отдельный коммит форматирования синхронизации, дальше S2–S9 и push;
|
|
||||||
- [TASK-GROK-3.md](TASK-GROK-3.md) — приёмка с интеграционными тестами и проверкой конфликтов, проверка «форматирование без поведения», храповик после влития, сценарий переноса арендатора;
|
|
||||||
- [TASK-CODEX-2.md](TASK-CODEX-2.md) — без изменений.
|
|
||||||
|
|
||||||
**Порядок влития, уточнённый:**
|
|
||||||
|
|
||||||
1. Grok.
|
|
||||||
2. Цепочка Antigravity: reject → c0.
|
|
||||||
3. Codex: форматирование без синхронизации, затем `fix-live-database`.
|
|
||||||
|
|
||||||
Цепочка Antigravity идёт раньше Codex, потому что после пересборки форматирования у Codex не останется пересечений с синхронизацией.
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
# Приёмка №3 — 2026-09-26
|
|
||||||
|
|
||||||
Проверка независимая, на живой PostgreSQL 17.10, в отдельной рабочей копии. Ветки исполнителей не менялись. Основная ветка — `b0243d5`.
|
|
||||||
|
|
||||||
## Codex
|
|
||||||
|
|
||||||
| Ветка | Коммит | Вердикт |
|
|
||||||
| --------------------------- | --------- | --------------------------------------------------------------------------------------- |
|
|
||||||
| `codex/fix-format-baseline` | `a05777c` | **Принято.** Пункт 1 TASK-CODEX-2 выполнен |
|
|
||||||
| `codex/fix-live-database` | `1573860` | **Принято.** K3/N2 и K4/N1 по-прежнему зелёные |
|
|
||||||
| `codex/fix-tenant-config` | `535052e` | **Почти:** пункт 2.1 сделан правильно, но `npm run check` на ветке красный. Одна строка |
|
|
||||||
|
|
||||||
### Форматирование — пункт 1 закрыт
|
|
||||||
|
|
||||||
Перепроверил тем же способом, что и в прошлый раз: взял каждый из 19 изменённых файлов в версии из основы, прогнал prettier и сравнил с версией из ветки. **Ни один файл не отличается после нормализации** — в коммите только стиль. Синхронизация из него убрана.
|
|
||||||
|
|
||||||
`codex/fix-live-database` перебазирован на новое форматирование. Стенд Grok поверх ветки: K3/N2 и K4/N1 зелёные, стенд падает только сообщением «дефект исправлен, обновите known-defects.json» — это правильное поведение храповика, а не ошибка ветки.
|
|
||||||
|
|
||||||
### Пункт 2.1 — заглушка больше не вернётся
|
|
||||||
|
|
||||||
Сделано по всем четырём местам:
|
|
||||||
|
|
||||||
| Где | Как |
|
|
||||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `deploy/pilot/setup.sh` | `validate_tenant()`: требует переменную, проверяет ULID, отдельно отказывает заглушке. Вызывается и при создании `.env`, и после его чтения |
|
|
||||||
| `compose.yaml` | `${STANDALONE_TENANT_ID:?…}` |
|
|
||||||
| `.env.example` | пусто, с комментарием откуда брать |
|
|
||||||
| `src/config.ts` | заглушка отклоняется, кроме `development` на loopback |
|
|
||||||
|
|
||||||
### Что мешает влить `codex/fix-tenant-config`
|
|
||||||
|
|
||||||
`npm run check` на ветке: **`Test Files 1 failed | 17 passed`**.
|
|
||||||
|
|
||||||
```text
|
|
||||||
FAIL tests/unit/sync.test.ts
|
|
||||||
Error: STANDALONE_TENANT_ID uses the development placeholder, which is allowed only in development on loopback
|
|
||||||
```
|
|
||||||
|
|
||||||
`tests/unit/sync.test.ts:553` берёт `STANDALONE_TENANT_ID = '01J00000000000000000000000'` с `NODE_ENV: 'test'`. Новая проверка это запрещает. Четыре теста под заглушку ты поправил, а этот — в зоне Antigravity, поэтому и не поправил. Правильно, что не полез.
|
|
||||||
|
|
||||||
**Как закрыть, не трогая чужую зону:** разрешить заглушку и при `NODE_ENV === 'test'`. Инвариант защищает развёртывание, а `NODE_ENV=test` пилот никогда не обслуживает — на пилоте стоит `production`, в `compose.yaml` заглушка уже невозможна. Тест в `tests/unit/config.test.ts`, который сейчас требует отказа в test-режиме, соответственно поменять: пусть проверяет отказ в `production` и в `development` на не-loopback.
|
|
||||||
|
|
||||||
**Готово, когда** `npm run check` на ветке даёт `Test Files 19 passed` (или 18 passed | 1 skipped, как на основе).
|
|
||||||
|
|
||||||
### Осталось по TASK-CODEX-2
|
|
||||||
|
|
||||||
- Пункт 2.2 — `scripts/rehome-tenant.mjs` нет. Grok уже подготовил сценарии `PILOT-REHOME` и `PILOT-REHOME-SEED` и описал ожидаемый интерфейс в `tests/e2e/NOTES-FOR-EXECUTORS.md`: `DATABASE_ADMIN_URL=… node scripts/rehome-tenant.mjs --from … --to … [--dry-run] [--json]`. Держись этого интерфейса — тогда сценарий позеленеет сам.
|
|
||||||
- Пункт 2.3 — скрипта сопоставления `core_user_id` нет.
|
|
||||||
- Перед пунктом 2.2 — резервная копия K18 и проверка восстановления.
|
|
||||||
|
|
||||||
## Слияния — всё чисто
|
|
||||||
|
|
||||||
| Пара | Результат |
|
|
||||||
| ----------------------------------------- | --------- |
|
|
||||||
| Codex format + AG c0 | чисто |
|
|
||||||
| Codex live-database + AG c0 | чисто |
|
|
||||||
| Codex tenant-config + Codex live-database | чисто |
|
|
||||||
| Codex tenant-config + Grok | чисто |
|
|
||||||
| Grok + Codex live-database | чисто |
|
|
||||||
|
|
||||||
Antigravity выстроил свои ветки в цепочку: `fix-reject-device-shifts` → `fix-sync-c0-bootstrap`. Конфликта больше нет.
|
|
||||||
|
|
||||||
## Порядок влития
|
|
||||||
|
|
||||||
1. `grok/e2e-harness`.
|
|
||||||
2. Цепочка Antigravity: `fix-reject-device-shifts` → `fix-sync-c0-bootstrap`.
|
|
||||||
3. Codex: `fix-format-baseline` → `fix-live-database` → `fix-tenant-config` (после исправления теста).
|
|
||||||
4. Grok — коммит в `known-defects.json`: убрать K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B, SYNC-C0.
|
|
||||||
|
|
||||||
Разделы по Grok и Antigravity добавляются в этот файл после их проверки.
|
|
||||||
|
|
@ -3,17 +3,13 @@
|
||||||
Рабочая папка аудита: исполнители берут отсюда задания и отчитываются через git.
|
Рабочая папка аудита: исполнители берут отсюда задания и отчитываются через git.
|
||||||
|
|
||||||
| Файл | Что это |
|
| Файл | Что это |
|
||||||
| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
||||||
| [AUDIT.md](AUDIT.md) | Полный аудит на живой PostgreSQL 17: находки P0–P3, сверка с аудитом Grok, решения владельца |
|
| [AUDIT.md](AUDIT.md) | Полный аудит на живой PostgreSQL 17: находки P0–P3, сверка с аудитом Grok, решения владельца |
|
||||||
| [ACCEPTANCE-1.md](ACCEPTANCE-1.md) | Приёмка первого круга работ Grok и Antigravity |
|
| [ACCEPTANCE-1.md](ACCEPTANCE-1.md) | Приёмка первого круга работ Grok и Antigravity |
|
||||||
| [TASK-CODEX.md](TASK-CODEX.md) | Codex: сервер, расчёт, база, пилот, терминал, роли, подтверждение и согласие — этапы 0–6 |
|
| [TASK-CODEX.md](TASK-CODEX.md) | Codex: сервер, расчёт, база, пилот, терминал, роли, подтверждение и согласие — этапы 0–6 |
|
||||||
| [ACCEPTANCE-2.md](ACCEPTANCE-2.md) | Приёмка второго круга: Grok принят, Codex и Antigravity — что доделать, порядок влития |
|
| [TASK-ANTIGRAVITY-2.md](TASK-ANTIGRAVITY-2.md) | Antigravity, **текущий круг**: синхронизация, push, оповещения, outbox |
|
||||||
| [ACCEPTANCE-3.md](ACCEPTANCE-3.md) | Приёмка третьего круга: Codex принят кроме одного красного теста, порядок влития |
|
| [TASK-GROK-2.md](TASK-GROK-2.md) | Grok, **текущий круг**: запуск стенда под Windows, сопоставление ID, недостающие сценарии, приёмка |
|
||||||
| [TASK-CODEX-2.md](TASK-CODEX-2.md) | Codex, **текущий круг**: форматирование без синхронизации, пилот на настоящего арендатора, сопоставление с платформой |
|
| [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md) | Задания первого круга, для истории |
|
||||||
| [TASK-ANTIGRAVITY-3.md](TASK-ANTIGRAVITY-3.md) | Antigravity, **текущий круг**: починить тесты, цепочка веток, дальше S2–S9 и push |
|
|
||||||
| [TASK-GROK-3.md](TASK-GROK-3.md) | Grok, **текущий круг**: приёмка с интеграционными тестами и конфликтами, перенос арендатора |
|
|
||||||
| [TASK-ANTIGRAVITY-2.md](TASK-ANTIGRAVITY-2.md) | Antigravity, круг 2: полный список S2–S9, push, оповещения, outbox — остаётся в силе |
|
|
||||||
| [TASK-GROK-2.md](TASK-GROK-2.md), [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md) | Прошлые круги, для истории |
|
|
||||||
| [evidence/](evidence) | Сценарии живой проверки и их фактический вывод |
|
| [evidence/](evidence) | Сценарии живой проверки и их фактический вывод |
|
||||||
|
|
||||||
## Как работать
|
## Как работать
|
||||||
|
|
@ -21,6 +17,6 @@
|
||||||
- Каждый исполнитель работает в своих ветках и только в своей зоне файлов — зоны перечислены в задании.
|
- Каждый исполнитель работает в своих ветках и только в своей зоне файлов — зоны перечислены в задании.
|
||||||
- Готовность пункта подтверждается зелёным сценарием стенда Grok (`node scripts/test/run-e2e.mjs`) на живой PostgreSQL, а не тестами на поддельной базе.
|
- Готовность пункта подтверждается зелёным сценарием стенда Grok (`node scripts/test/run-e2e.mjs`) на живой PostgreSQL, а не тестами на поддельной базе.
|
||||||
- Отчёт о выполненном — в описании ветки или коммита: ID, коммит, какой сценарий стал зелёным.
|
- Отчёт о выполненном — в описании ветки или коммита: ID, коммит, какой сценарий стал зелёным.
|
||||||
- Приёмку каждой ветки независимо проводит Grok; итоги — в файлах `ACCEPTANCE-N.md`.
|
- Приёмку каждой ветки независимо проводит Grok; итог следующего круга появится здесь как `ACCEPTANCE-2.md`.
|
||||||
|
|
||||||
Решения владельца (роли, точки, процесс подтверждения, согласие) — раздел 12 в [AUDIT.md](AUDIT.md) и раздел «Решения владельца» в [TASK-CODEX.md](TASK-CODEX.md).
|
Решения владельца (роли, точки, процесс подтверждения, согласие) — раздел 12 в [AUDIT.md](AUDIT.md) и раздел «Решения владельца» в [TASK-CODEX.md](TASK-CODEX.md).
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
# Задание для Antigravity, круг 3: довести две ветки синхронизации до влития
|
|
||||||
|
|
||||||
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
|
||||||
- Основа: `codex/bootstrap-platform-foundation` (`f5ac179`)
|
|
||||||
- Итоги приёмки: `docs/audit/2026-09-26/ACCEPTANCE-2.md`. Задание `TASK-ANTIGRAVITY-2.md` остаётся в силе, этот файл его дополняет и задаёт порядок.
|
|
||||||
|
|
||||||
## Итог приёмки твоих веток
|
|
||||||
|
|
||||||
| Ветка | Коммит | Что подтверждено | Что мешает влить |
|
|
||||||
| -------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `antigravity/fix-reject-device-shifts` | `1df2019` | S1 исправлен: у Grok SYNC-A и SYNC-B зелёные | `tests/integration/sync-rejection.test.ts:81` вставляет столбец `created_by` в `staff_employees`, а такого столбца нет. Файл целиком FAIL |
|
|
||||||
| `antigravity/fix-sync-c0-bootstrap` | `8baad55` | SYNC-C0 исправлен: на стенде Grok зелёный, оба теста `sync-bootstrap.test.ts` на реальной базе проходят | Файл всё равно FAIL: `Called end on pool more than once` в `afterAll` (строка 96). Пул закрывает `app.close()` через хук `onClose` (`src/app.ts:148`), а тест закрывает его второй раз |
|
|
||||||
| обе вместе | — | — | **Конфликт** в `src/domain/sync/service.ts`: обе ветки созданы от `f5ac179` и правят один файл |
|
|
||||||
|
|
||||||
Проверка у меня: PostgreSQL 17.10, роль приложения без SUPERUSER и BYPASSRLS, миграции 0001–0011 на чистой базе.
|
|
||||||
|
|
||||||
## 1. Починить тесты (сегодня)
|
|
||||||
|
|
||||||
**1.1. `sync-rejection.test.ts`.** Подготовку данных сверить со схемой из миграций, а не писать по памяти: в `staff_employees` нет `created_by`. Смотри `migrations/*.up.sql` или `\d staff_employees`.
|
|
||||||
|
|
||||||
**1.2. `sync-bootstrap.test.ts`.** Закрывать пул один раз. Либо только `app.close()`, либо свою `Database`, которую приложение не закрывает, — но не обе сразу.
|
|
||||||
|
|
||||||
**Готово, когда** на реальной базе оба файла дают `Test Files 1 passed`, а не только `Tests N passed`: ошибка в хуке тоже делает файл красным, и CI его не пропустит.
|
|
||||||
|
|
||||||
```text
|
|
||||||
DATABASE_TEST_URL=<роль приложения> DATABASE_MIGRATION_URL=<мигратор> npx vitest run tests/integration/sync-rejection.test.ts tests/integration/sync-bootstrap.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Одна цепочка вместо двух конфликтующих веток
|
|
||||||
|
|
||||||
Две ветки от одной основы с правками одного файла нельзя влить обе без ручного разрешения конфликта, а разрешать его за тебя никто не будет. Сделай цепочку:
|
|
||||||
|
|
||||||
1. `antigravity/fix-reject-device-shifts` — исправленный тест (пункт 1.1), без новых функций.
|
|
||||||
2. `antigravity/fix-sync-c0-bootstrap` — **перебазировать поверх** пункта 1 и разрешить конфликт у себя. Исправленный тест пункта 1.2 — здесь же.
|
|
||||||
3. Пуш обеих веток с `--force-with-lease`.
|
|
||||||
|
|
||||||
Тогда вливается сначала первая ветка, потом вторая — без конфликтов.
|
|
||||||
|
|
||||||
**Готово, когда:**
|
|
||||||
|
|
||||||
- `git merge-base --is-ancestor origin/antigravity/fix-reject-device-shifts origin/antigravity/fix-sync-c0-bootstrap` выполняется успешно;
|
|
||||||
- на вершине цепочки у Grok зелёные SYNC-A, SYNC-B и SYNC-C0, и оба интеграционных файла дают `passed`.
|
|
||||||
|
|
||||||
## 3. Форматирование синхронизации — твоё
|
|
||||||
|
|
||||||
В ветке Codex `codex/fix-format-baseline` (`513cdff`) до сих пор лежит твоё исправление S1: после prettier `service.ts` и `sync.test.ts` в ней совпадают с твоей веткой построчно. Codex получил задание убрать оттуда `src/domain/sync/**` и `tests/unit/sync.test.ts`. Поэтому:
|
|
||||||
|
|
||||||
- форматирование файлов синхронизации (`prettier --write src/domain/sync tests/unit/sync.test.ts tests/integration/sync-*.test.ts`) делаешь ты, в своей цепочке, **отдельным коммитом** `style(sync): …` без изменений поведения;
|
|
||||||
- после этого `npx prettier --check src/domain/sync tests/unit/sync.test.ts` на вершине цепочки проходит.
|
|
||||||
|
|
||||||
## 4. Перед каждым пушем
|
|
||||||
|
|
||||||
В прошлых двух кругах интеграционный тест пушился без запуска на настоящей базе. Порядок перед каждым пушем:
|
|
||||||
|
|
||||||
1. `npm run check`.
|
|
||||||
2. Интеграционные тесты своей зоны на реальной PostgreSQL — результат `Test Files … passed`.
|
|
||||||
3. Стенд Grok: `node scripts/test/run-e2e.mjs` поверх своей ветки. Ни один сценарий, зелёный на основе, не должен покраснеть.
|
|
||||||
4. В описании коммита или ветки — вывод пунктов 2 и 3 (строки `Test Files`, `Tests`, таблица по ID).
|
|
||||||
|
|
||||||
Если реальная база недоступна, так и напиши в отчёте. Не пиши «проверено», если не запускал.
|
|
||||||
|
|
||||||
## 5. Дальше — по TASK-ANTIGRAVITY-2, новыми ветками поверх цепочки
|
|
||||||
|
|
||||||
После пунктов 1–3 продолжай по разделам 3–6 `TASK-ANTIGRAVITY-2.md`. Одна тема — одна ветка, каждая следующая — от вершины предыдущей, пока они не влиты:
|
|
||||||
|
|
||||||
| Порядок | Ветка | ID | Готово, когда |
|
|
||||||
| ------- | ------------------------------- | ------------------ | --------------------------------------------------------------------------------------------- |
|
|
||||||
| 1 | `antigravity/sync-device-scope` | S4, S8, S9 | SYNC-E, SYNC-F, SYNC-H зелёные; точку с устройством можно удалить или получить понятный отказ |
|
|
||||||
| 2 | `antigravity/sync-idempotency` | S3, S2 | SYNC-G, SYNC-J зелёные |
|
|
||||||
| 3 | `antigravity/sync-cursor` | S5, S6, S7 | SYNC-C, SYNC-D зелёные; отметка через синхронизацию пишет outbox |
|
|
||||||
| 4 | `antigravity/push-hardening` | K12, N8, N9, U1–U7 | N3 и N9 зелёные |
|
|
||||||
| 5 | `antigravity/outbox-publisher` | раздел 6 | по TASK-ANTIGRAVITY-2 |
|
|
||||||
|
|
||||||
Оповещения процесса подтверждения (раздел 5 TASK-ANTIGRAVITY-2) — только после того, как Codex вольёт этап 4.
|
|
||||||
|
|
||||||
Зона файлов прежняя: `src/domain/sync/**`, `src/domain/notifications/**`, push-модули, их миграции и тесты. Если нужна правка вне зоны — отдельный коммит с объяснением и пометкой в отчёте. Лучше попроси Codex.
|
|
||||||
|
|
||||||
## Отчёт
|
|
||||||
|
|
||||||
Для каждой ветки: имя, коммит вершины, основа, какие ID закрыты, вывод интеграционных тестов и стенда Grok, что осталось.
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
# Задание для Codex, круг 2: исправить ветку форматирования, перевести пилот на настоящего арендатора
|
|
||||||
|
|
||||||
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
|
||||||
- Основа: `codex/bootstrap-platform-foundation` (`f5ac179`)
|
|
||||||
- Итоги приёмки: `docs/audit/2026-09-26/ACCEPTANCE-2.md`. Основное задание `TASK-CODEX.md` остаётся в силе, этот файл его дополняет.
|
|
||||||
|
|
||||||
## 1. Пересобрать ветку форматирования (сегодня)
|
|
||||||
|
|
||||||
По данным приёмки, коммит `fd1ec3d` в ветке `codex/fix-format-baseline` содержит, кроме форматирования, функциональное изменение Antigravity. После нормализации prettier файлы `src/domain/sync/service.ts` и `tests/unit/sync.test.ts` совпадают с его исправлением S1. Это одновременно смешение тем и правка чужой зоны (AGENTS.md). Кроме того, при влитии обеих веток изменение придёт дважды.
|
|
||||||
|
|
||||||
**Что сделать:** пересобрать коммит форматирования от `f5ac179` так, чтобы в нём было только `prettier --write`, без изменений в `src/domain/sync/**` и `tests/unit/sync.test.ts`. Исправление S1 должно приходить только из ветки Antigravity. Затем перебазировать `codex/fix-live-database` на новый коммит форматирования.
|
|
||||||
|
|
||||||
**Готово, когда:**
|
|
||||||
|
|
||||||
- `git diff <новый коммит>~1 <новый коммит> -w` после `prettier --check` не содержит изменений поведения;
|
|
||||||
- стенд Grok на `codex/fix-live-database` даёт K3/N2 и K4/N1 зелёными, а K13/SYNC-A и K13/SYNC-B — красными (они придут от Antigravity).
|
|
||||||
|
|
||||||
## 2. Пилот — на настоящего арендатора
|
|
||||||
|
|
||||||
По данным сессии «БП» (запросы только на чтение к `krona-staff-pilot-postgres-1`), под заглушкой `01J00000000000000000000000` сейчас лежит:
|
|
||||||
|
|
||||||
| Таблица | Строк |
|
|
||||||
| ---------------------- | ----- |
|
|
||||||
| `staff_employees` | 51 |
|
|
||||||
| `staff_payroll_ledger` | 3 830 |
|
|
||||||
| `staff_work_items` | 3 353 |
|
|
||||||
| `staff_daily_metrics` | 2 894 |
|
|
||||||
| `staff_audit_log` | 49 |
|
|
||||||
|
|
||||||
Остальные таблицы — единицы строк.
|
|
||||||
|
|
||||||
Настоящий арендатор владельца в платформе — `01M2R6AC5652NPT3MG34DKN4ZP` («Сушигедза»).
|
|
||||||
|
|
||||||
### 2.1 Чтобы заглушка не вернулась
|
|
||||||
|
|
||||||
| Где | Что сделать |
|
|
||||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `deploy/pilot/setup.sh:20` | Не генерировать `STANDALONE_TENANT_ID` со значением-заглушкой: требовать его явно, без значения по умолчанию, с понятной ошибкой |
|
|
||||||
| `compose.yaml:16` | `${STANDALONE_TENANT_ID:?…}` вместо `${STANDALONE_TENANT_ID:-01J000…}` |
|
|
||||||
| `.env.example:14` | Оставить пустым с комментарием, откуда берётся значение (идентификатор организации в платформе) |
|
|
||||||
| `src/config.ts` | Вне режима разработки на loopback отклонять `01J00000000000000000000000` при старте как известную заглушку |
|
|
||||||
|
|
||||||
### 2.2 Перенос уже внесённых данных
|
|
||||||
|
|
||||||
Способ выбираешь сам: однократная перезапись `tenant_id` или переимпорт книги владельца. В обоих случаях обязательно:
|
|
||||||
|
|
||||||
1. **Сначала резервная копия** (задача K18 из основного задания) и проверка, что из неё действительно можно восстановиться. Без этого перенос не начинать.
|
|
||||||
2. **Сначала прогон на копии** пилотной базы, затем на пилоте в согласованное окно, при остановленном приложении. Терминалы на это время копят отметки у себя.
|
|
||||||
3. **Перезапись выполняет привилегированная роль администратора БД**, а не роль приложения. RLS с `FORCE`, составные внешние ключи `(tenant_id, id)`, неизменяемые журналы (`staff_payroll_ledger`, `staff_audit_log`) — всё это нужно учесть явно: одна транзакция, внешние ключи переустанавливаются в ней же, триггеры неизменяемости отключаются только внутри неё.
|
|
||||||
4. **Скрипт в репозитории:** `scripts/rehome-tenant.mjs` (или аналог) с режимом `--dry-run` (только отчёт о количестве строк по таблицам) и отказом работать, если целевой арендатор уже содержит данные.
|
|
||||||
5. **Запись о переносе** в `staff_audit_log` нового арендатора: кто, когда, откуда, куда, сколько строк.
|
|
||||||
|
|
||||||
**Готово, когда:**
|
|
||||||
|
|
||||||
- `SELECT DISTINCT tenant_id` по всем таблицам со столбцом `tenant_id` возвращает только `01M2R6AC5652NPT3MG34DKN4ZP`;
|
|
||||||
- количество строк в каждой таблице до и после совпадает;
|
|
||||||
- сумма `amount_minor` в реестре начислений до и после совпадает;
|
|
||||||
- пилот под новым арендатором показывает тех же 51 сотрудника;
|
|
||||||
- тест изоляции арендаторов зелёный.
|
|
||||||
|
|
||||||
Вывод `--dry-run` и итоговые запросы приложи к отчёту. Grok проверит скрипт на копии: данные под старым арендатором → перенос → сверка.
|
|
||||||
|
|
||||||
### 2.3 Скрипт сопоставления с платформой
|
|
||||||
|
|
||||||
`docs/PLATFORM-INTEGRATION.md` обещает сопоставление standalone ULID с записями Core, но скрипта нет. После пункта 2.2 перенос арендатора уже решён, поэтому скрипт сводится к заполнению `core_user_id` у сотрудников:
|
|
||||||
|
|
||||||
- вход — выгрузка пользователей платформы (JSON или CSV: идентификатор пользователя Core и признак сопоставления — табельный номер, e-mail или телефон);
|
|
||||||
- режим `--dry-run` с отчётом: сопоставлены, не найдены, найдены неоднозначно;
|
|
||||||
- идемпотентность: повторный запуск ничего не меняет, существующие `core_user_id` не перезаписываются без флага;
|
|
||||||
- запись в журнал аудита.
|
|
||||||
|
|
||||||
**Готово, когда** скрипт есть, покрыт тестом на реальной PostgreSQL и описан в `docs/PLATFORM-INTEGRATION.md`.
|
|
||||||
|
|
||||||
## 3. Не делать
|
|
||||||
|
|
||||||
**Не менять `PlatformIdentityAdapter` под нынешние неподписанные заголовки `x-tenant-id`**, которые платформа передаёт модулю. Договор будет с подписью: пункты А-5, С-6, С-7 плана платформы. Им занимается сессия «БП» в репозитории `business-platform`. Режим `platform` остаётся недоступным, пока платформа этого не сделает.
|
|
||||||
|
|
||||||
## Порядок
|
|
||||||
|
|
||||||
Пункт 1 → пункт 2.1 → резервная копия (K18) → пункт 2.2 на копии → 2.2 на пилоте → пункт 2.3. Параллельно продолжается основное задание: этап 2 — доступ и пилот, этап 3 — деньги (K5 и K6 теперь воспроизводятся по существу).
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
# Задание для Grok, круг 3: приёмка с интеграционными тестами, конфликты веток, сценарий переноса арендатора
|
|
||||||
|
|
||||||
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
|
|
||||||
- Основа: `codex/bootstrap-platform-foundation` (`f5ac179`)
|
|
||||||
- Итоги приёмки: `docs/audit/2026-09-26/ACCEPTANCE-2.md`. Ветка `grok/e2e-harness` (`ba59a48`) принята и вливается первой.
|
|
||||||
|
|
||||||
## Итог прошлого круга
|
|
||||||
|
|
||||||
Принято без замечаний по существу: стенд работает на Windows без правок, ID сопоставлены с аудитом, недостающие сценарии добавлены, приёмка ветки Codex совпала с моей до цифры (41 красный / 13 зелёных). Твоя находка про поведение в «стилевом» коммите Codex подтверждена.
|
|
||||||
|
|
||||||
Одно упущение: в приёмке запускались только `tests/e2e`, а интеграционные тесты самих исполнителей — нет. Из-за этого два красных файла Antigravity прошли бы как «принято».
|
|
||||||
|
|
||||||
## Правила — как раньше
|
|
||||||
|
|
||||||
Только твоя зона: `tests/e2e/**`, `scripts/test/**`, `known-defects.json`, CI. В код исполнителей не коммитишь. Ветки исполнителей не меняешь — проверяешь в отдельных рабочих копиях.
|
|
||||||
|
|
||||||
## 1. Приёмка = стенд + интеграционные тесты + конфликты
|
|
||||||
|
|
||||||
Добавь в `scripts/test/` режим приёмки ветки, например `node scripts/test/accept-branch.mjs <ветка> [<ветка>…]`. Для каждой ветки он:
|
|
||||||
|
|
||||||
1. Создаёт отдельную рабочую копию (`git worktree`) и накладывает текущий стенд.
|
|
||||||
2. Запускает `npm run check`.
|
|
||||||
3. Запускает **все** `tests/integration/*.test.ts` на реальной PostgreSQL. Считает файл красным по итоговой строке `Test Files`, а не `Tests`: ошибка в `beforeAll`/`afterAll` делает файл красным даже при зелёных тестах.
|
|
||||||
4. Запускает e2e-стенд и сравнивает с основой: что позеленело, что покраснело.
|
|
||||||
5. Проверяет попарно слияние всех переданных веток между собой и с основой (`git merge-tree --write-tree`). Печатает таблицу «чисто / конфликт, файлы».
|
|
||||||
6. Печатает итог в markdown, чтобы его можно было вставить в отчёт.
|
|
||||||
|
|
||||||
**Роли базы.** Стенд пересоздаёт роли со случайными паролями на общем кластере — это ломает параллельную работу других. Сделай так, чтобы роли и пароли можно было передать переменными (`E2E_MIGRATOR_URL`, `E2E_APP_URL`) и стенд их тогда не трогал. По умолчанию — как сейчас.
|
|
||||||
|
|
||||||
**Готово, когда** запуск на `antigravity/fix-reject-device-shifts antigravity/fix-sync-c0-bootstrap codex/fix-format-baseline` сам показывает то, что я нашёл вручную:
|
|
||||||
|
|
||||||
| Что | Ожидаемый результат на сегодня |
|
|
||||||
| ------------------------------------- | -------------------------------------------------------------------- |
|
|
||||||
| `sync-rejection.test.ts` на `1df2019` | FAIL: `column "created_by" … does not exist` |
|
|
||||||
| `sync-bootstrap.test.ts` на `8baad55` | FAIL: `Called end on pool more than once` при `Tests 2 passed` |
|
|
||||||
| SYNC-C0 на `8baad55` | зелёный |
|
|
||||||
| AG reject + AG c0 | конфликт, `src/domain/sync/service.ts` |
|
|
||||||
| Codex format + AG c0 | конфликт |
|
|
||||||
| Codex format + AG reject | чисто (потому что в форматировании Codex лежит копия исправления S1) |
|
|
||||||
|
|
||||||
## 2. Проверка «форматирование без поведения»
|
|
||||||
|
|
||||||
Для ветки `codex/fix-format-baseline` (сейчас `513cdff`) добавь в режим приёмки проверку: для каждого изменённого файла взять версию из основы, прогнать через prettier и сравнить с версией из ветки. Любое отличие после нормализации — это поведение, а не стиль. Печатать список таких файлов.
|
|
||||||
|
|
||||||
На сегодня ожидается: `src/domain/sync/service.ts` и `tests/unit/sync.test.ts` отличаются. После нормализации они совпадают с веткой Antigravity построчно. Я проверил это на `513cdff`: Codex перезалил ветку, но синхронизацию не убрал.
|
|
||||||
|
|
||||||
**Готово, когда** проверка ловит `513cdff` и пропускает ветку, где остался только prettier.
|
|
||||||
|
|
||||||
## 3. Храповик после каждого влития
|
|
||||||
|
|
||||||
Когда владелец вливает ветку, отдельным коммитом убери из `known-defects.json` то, что стало зелёным на основе:
|
|
||||||
|
|
||||||
| После влития | Убрать |
|
|
||||||
| --------------------------------- | ------------------------------- |
|
|
||||||
| `codex/fix-live-database` | K3/N2, K4/N1 |
|
|
||||||
| цепочка Antigravity (reject → c0) | K13/SYNC-A, K13/SYNC-B, SYNC-C0 |
|
|
||||||
|
|
||||||
Коммит — только `known-defects.json`, в теле — вывод стенда на основе после влития.
|
|
||||||
|
|
||||||
## 4. Сценарий переноса арендатора (для TASK-CODEX-2, п. 2.2)
|
|
||||||
|
|
||||||
Codex пишет `scripts/rehome-tenant.mjs`: перенос всех данных пилота из заглушки `01J00000000000000000000000` в арендатора `01M2R6AC5652NPT3MG34DKN4ZP`. Подготовь проверку заранее, чтобы принять скрипт в день пуша:
|
|
||||||
|
|
||||||
1. Наполнить базу под заглушкой: сотрудники, точки, смены, начисления в реестре, строки аудита, устройства синхронизации, push-подписки — по несколько строк в каждой таблице со столбцом `tenant_id`. Плюс второй, посторонний арендатор с собственными данными.
|
|
||||||
2. Снять контрольные цифры: число строк по каждой таблице и арендатору, `SUM(amount_minor)` реестра.
|
|
||||||
3. `--dry-run` — ничего не меняет, печатает те же числа.
|
|
||||||
4. Перенос ролью администратора БД.
|
|
||||||
5. Сверка:
|
|
||||||
- `DISTINCT tenant_id` — только новый арендатор и посторонний;
|
|
||||||
- число строк и суммы совпадают;
|
|
||||||
- данные постороннего арендатора не тронуты;
|
|
||||||
- в `staff_audit_log` нового арендатора есть запись о переносе;
|
|
||||||
- приложение ролью `krona_staff_app` под новым арендатором видит тех же сотрудников, а под старым — ничего;
|
|
||||||
- журналы остались неизменяемыми: после переноса `UPDATE` в `staff_payroll_ledger` отклоняется.
|
|
||||||
6. Повторный запуск отказывает: целевой арендатор уже содержит данные.
|
|
||||||
|
|
||||||
Пока скрипта нет, сценарий красный с понятным сообщением и заносится в `known-defects.json` как `PILOT-REHOME`. Список таблиц со столбцом `tenant_id` бери из `information_schema.columns`, а не руками — так новая таблица без переноса сразу даст красный.
|
|
||||||
|
|
||||||
## Порядок
|
|
||||||
|
|
||||||
1 → 2 → прогнать приёмку по всем открытым веткам и выложить результат → 3 по мере влития → 4.
|
|
||||||
|
|
||||||
## Отчёт
|
|
||||||
|
|
||||||
- ветка и коммит;
|
|
||||||
- вывод режима приёмки по всем открытым веткам (`codex/*`, `antigravity/*`) на момент отчёта;
|
|
||||||
- что изменилось в `known-defects.json`;
|
|
||||||
- что не запускалось и почему.
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
ALTER TABLE staff_sync_command_inbox
|
|
||||||
DROP CONSTRAINT IF EXISTS staff_sync_command_inbox_correlation_id_check,
|
|
||||||
DROP CONSTRAINT IF EXISTS staff_sync_command_inbox_causation_id_check;
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_command_inbox
|
|
||||||
ALTER COLUMN correlation_id TYPE VARCHAR(36),
|
|
||||||
ALTER COLUMN causation_id TYPE VARCHAR(36);
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_devices
|
|
||||||
DROP CONSTRAINT IF EXISTS staff_sync_devices_tenant_id_org_unit_id_fkey;
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_devices
|
|
||||||
ADD CONSTRAINT staff_sync_devices_tenant_id_org_unit_id_fkey
|
|
||||||
FOREIGN KEY (tenant_id, org_unit_id)
|
|
||||||
REFERENCES staff_org_units (tenant_id, id)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
ALTER TABLE staff_sync_devices
|
|
||||||
DROP CONSTRAINT IF EXISTS staff_sync_devices_tenant_id_org_unit_id_fkey;
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_devices
|
|
||||||
ADD CONSTRAINT staff_sync_devices_tenant_id_org_unit_id_fkey
|
|
||||||
FOREIGN KEY (tenant_id, org_unit_id)
|
|
||||||
REFERENCES staff_org_units (tenant_id, id)
|
|
||||||
ON DELETE RESTRICT;
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_command_inbox
|
|
||||||
ALTER COLUMN correlation_id TYPE VARCHAR(26),
|
|
||||||
ALTER COLUMN causation_id TYPE VARCHAR(26);
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_command_inbox
|
|
||||||
ADD CONSTRAINT staff_sync_command_inbox_correlation_id_check
|
|
||||||
CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
ADD CONSTRAINT staff_sync_command_inbox_causation_id_check
|
|
||||||
CHECK (causation_id IS NULL OR causation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$');
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
DROP TABLE IF EXISTS staff_biometric_consents;
|
|
||||||
DROP TABLE IF EXISTS staff_batch_notifications;
|
|
||||||
DROP TABLE IF EXISTS staff_payroll_batch_lines;
|
|
||||||
DROP TABLE IF EXISTS staff_payroll_batches;
|
|
||||||
DROP TABLE IF EXISTS staff_access_grants;
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_devices
|
|
||||||
DROP CONSTRAINT IF EXISTS staff_sync_devices_tenant_id_org_unit_id_fkey;
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_devices
|
|
||||||
ADD CONSTRAINT staff_sync_devices_tenant_id_org_unit_id_fkey
|
|
||||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL;
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
CREATE TABLE staff_access_grants (
|
|
||||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
role VARCHAR(32) NOT NULL CHECK (role IN ('point_manager', 'accountant', 'director')),
|
|
||||||
org_unit_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
token_hash CHAR(64) NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
|
|
||||||
token_hint VARCHAR(16) NOT NULL,
|
|
||||||
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
UNIQUE (tenant_id, id),
|
|
||||||
UNIQUE (tenant_id, token_hash)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE staff_access_grants ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE staff_access_grants FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY staff_access_grants_tenant_isolation
|
|
||||||
ON staff_access_grants
|
|
||||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
|
||||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_access_grants TO krona_staff_app;
|
|
||||||
|
|
||||||
CREATE TABLE staff_payroll_batches (
|
|
||||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
org_unit_id VARCHAR(26) NOT NULL,
|
|
||||||
period_start DATE NOT NULL,
|
|
||||||
period_end DATE NOT NULL,
|
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'signed', 'returned')),
|
|
||||||
return_comment TEXT,
|
|
||||||
submitted_at TIMESTAMPTZ,
|
|
||||||
submitted_by VARCHAR(26),
|
|
||||||
signed_at TIMESTAMPTZ,
|
|
||||||
signed_by VARCHAR(26),
|
|
||||||
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (tenant_id, id),
|
|
||||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE staff_payroll_batches ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE staff_payroll_batches FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY staff_payroll_batches_tenant_isolation
|
|
||||||
ON staff_payroll_batches
|
|
||||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
|
||||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON staff_payroll_batches TO krona_staff_app;
|
|
||||||
|
|
||||||
CREATE TABLE staff_payroll_batch_lines (
|
|
||||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
batch_id VARCHAR(26) NOT NULL,
|
|
||||||
work_session_id VARCHAR(26) NOT NULL,
|
|
||||||
employee_id VARCHAR(26) NOT NULL,
|
|
||||||
org_unit_id VARCHAR(26) NOT NULL,
|
|
||||||
amount_minor BIGINT NOT NULL,
|
|
||||||
session_revision INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (tenant_id, id),
|
|
||||||
FOREIGN KEY (tenant_id, batch_id) REFERENCES staff_payroll_batches (tenant_id, id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (tenant_id, work_session_id) REFERENCES staff_work_sessions (tenant_id, id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE staff_payroll_batch_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE staff_payroll_batch_lines FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY staff_payroll_batch_lines_tenant_isolation
|
|
||||||
ON staff_payroll_batch_lines
|
|
||||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
|
||||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_payroll_batch_lines TO krona_staff_app;
|
|
||||||
|
|
||||||
CREATE TABLE staff_batch_notifications (
|
|
||||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
recipient_role VARCHAR(32) NOT NULL,
|
|
||||||
recipient_actor_id VARCHAR(26),
|
|
||||||
type VARCHAR(64) NOT NULL,
|
|
||||||
subject_id VARCHAR(26) NOT NULL,
|
|
||||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (tenant_id, id)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE staff_batch_notifications ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE staff_batch_notifications FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY staff_batch_notifications_tenant_isolation
|
|
||||||
ON staff_batch_notifications
|
|
||||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
|
||||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_batch_notifications TO krona_staff_app;
|
|
||||||
|
|
||||||
CREATE TABLE staff_biometric_consents (
|
|
||||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
employee_id VARCHAR(26) NOT NULL,
|
|
||||||
status VARCHAR(32) NOT NULL CHECK (status IN ('granted', 'revoked')),
|
|
||||||
document_ref TEXT,
|
|
||||||
updated_by VARCHAR(26) NOT NULL CHECK (updated_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (tenant_id, employee_id),
|
|
||||||
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE staff_biometric_consents ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE staff_biometric_consents FORCE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY staff_biometric_consents_tenant_isolation
|
|
||||||
ON staff_biometric_consents
|
|
||||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
|
||||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_biometric_consents TO krona_staff_app;
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_devices
|
|
||||||
DROP CONSTRAINT IF EXISTS staff_sync_devices_tenant_id_org_unit_id_fkey;
|
|
||||||
|
|
||||||
ALTER TABLE staff_sync_devices
|
|
||||||
ADD CONSTRAINT staff_sync_devices_tenant_id_org_unit_id_fkey
|
|
||||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL (org_unit_id);
|
|
||||||
60
package-lock.json
generated
60
package-lock.json
generated
|
|
@ -1074,6 +1074,9 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1092,6 +1095,9 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1110,6 +1116,9 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1128,6 +1137,9 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1146,6 +1158,9 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1164,6 +1179,9 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1489,9 +1507,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.12",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -1673,9 +1691,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv/node_modules/fast-uri": {
|
"node_modules/ajv/node_modules/fast-uri": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -1758,9 +1776,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2247,9 +2265,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "4.2.1",
|
"version": "4.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
|
||||||
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
|
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2644,9 +2662,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2882,6 +2900,9 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2904,6 +2925,9 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2926,6 +2950,9 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2948,6 +2975,9 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Run on the backup PC (Git Bash), after the server has made encrypted backups.
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
|
|
||||||
: "${PILOT_SSH_TARGET:?Set the configured SSH alias for the pilot server}"
|
|
||||||
: "${PILOT_REMOTE_BACKUP_DIRECTORY:?Set its absolute encrypted backup directory}"
|
|
||||||
: "${BACKUP_LOCAL_DIRECTORY:?Set an absolute backup directory on this PC}"
|
|
||||||
BACKUP_LOCAL_RETENTION_DAYS=${BACKUP_LOCAL_RETENTION_DAYS:-90}
|
|
||||||
[[ "$BACKUP_LOCAL_RETENTION_DAYS" =~ ^[1-9][0-9]{0,4}$ ]] &&
|
|
||||||
(( BACKUP_LOCAL_RETENTION_DAYS <= 36500 )) || {
|
|
||||||
echo 'BACKUP_LOCAL_RETENTION_DAYS must be an integer from 1 to 36500' >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[[ "$PILOT_SSH_TARGET" =~ ^[a-zA-Z0-9_.@-]+$ ]] || { echo 'Invalid SSH target' >&2; exit 1; }
|
|
||||||
[[ "$PILOT_REMOTE_BACKUP_DIRECTORY" =~ ^/[a-zA-Z0-9_./-]+$ ]] || { echo 'Invalid server backup directory' >&2; exit 1; }
|
|
||||||
[[ "$PILOT_REMOTE_BACKUP_DIRECTORY" != */../* && "$PILOT_REMOTE_BACKUP_DIRECTORY" != */.. ]] || { echo 'Parent traversal is not allowed' >&2; exit 1; }
|
|
||||||
[[ "$PILOT_REMOTE_BACKUP_DIRECTORY" != / ]] || { echo 'Use a dedicated server backup directory' >&2; exit 1; }
|
|
||||||
[[ "$BACKUP_LOCAL_DIRECTORY" = /* && "$BACKUP_LOCAL_DIRECTORY" != / ]] || { echo 'Use a dedicated absolute local directory' >&2; exit 1; }
|
|
||||||
[[ "$BACKUP_LOCAL_DIRECTORY" != */../* && "$BACKUP_LOCAL_DIRECTORY" != */.. ]] || { echo 'Parent traversal is not allowed' >&2; exit 1; }
|
|
||||||
[[ ! -L "$BACKUP_LOCAL_DIRECTORY" ]] || { echo 'Local directory must not be a symlink' >&2; exit 1; }
|
|
||||||
|
|
||||||
mkdir -p -- "$BACKUP_LOCAL_DIRECTORY"
|
|
||||||
local_dir=$(cd -- "$BACKUP_LOCAL_DIRECTORY" && pwd -P)
|
|
||||||
[[ "$local_dir" != / && "$local_dir" != /tmp && "$local_dir" != /var && "$local_dir" != /home ]] || {
|
|
||||||
echo 'Local backup path resolves to a broad directory' >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
cd -- "$local_dir"
|
|
||||||
|
|
||||||
remote_dir=${PILOT_REMOTE_BACKUP_DIRECTORY%/}
|
|
||||||
listing=$(ssh -o BatchMode=yes -- "$PILOT_SSH_TARGET" \
|
|
||||||
"find '$remote_dir' -maxdepth 1 -type f -name 'krona-staff-*.dump.cms' -printf '%f\n'")
|
|
||||||
[[ -n "$listing" ]] || { echo 'No encrypted backups found on server' >&2; exit 1; }
|
|
||||||
|
|
||||||
copied=0
|
|
||||||
latest_backup_epoch=0
|
|
||||||
while IFS= read -r name; do
|
|
||||||
[[ "$name" =~ ^krona-staff-[0-9]{8}T[0-9]{6}Z-[0-9]+\.dump\.cms$ ]] || {
|
|
||||||
echo 'Unexpected backup filename from server' >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
timestamp=${name#krona-staff-}
|
|
||||||
timestamp=${timestamp%%-*}
|
|
||||||
backup_epoch=$(date -u -d "${timestamp:0:4}-${timestamp:4:2}-${timestamp:6:2} ${timestamp:9:2}:${timestamp:11:2}:${timestamp:13:2} UTC" +%s)
|
|
||||||
if (( backup_epoch > latest_backup_epoch )); then
|
|
||||||
latest_backup_epoch=$backup_epoch
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -e "$name" || -e "$name.sha256" ]]; then
|
|
||||||
[[ -f "$name" && -f "$name.sha256" ]] || { echo "Incomplete local copy: $name" >&2; exit 1; }
|
|
||||||
sha256sum -c -- "$name.sha256" >/dev/null || { echo "Local checksum mismatch: $name" >&2; exit 1; }
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
encrypted_partial="$name.partial"
|
|
||||||
checksum_partial="$name.sha256.partial"
|
|
||||||
[[ ! -e "$encrypted_partial" && ! -e "$checksum_partial" ]] || {
|
|
||||||
echo "Interrupted prior copy needs inspection: $name" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
scp -B -- "$PILOT_SSH_TARGET:$remote_dir/$name" "$encrypted_partial"
|
|
||||||
scp -B -- "$PILOT_SSH_TARGET:$remote_dir/$name.sha256" "$checksum_partial"
|
|
||||||
expected=$(awk 'NR == 1 { print $1 }' "$checksum_partial")
|
|
||||||
[[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || { echo "Malformed checksum: $name" >&2; exit 1; }
|
|
||||||
actual=$(sha256sum -- "$encrypted_partial")
|
|
||||||
[[ "${actual%% *}" = "$expected" ]] || { echo "Transfer checksum mismatch: $name" >&2; exit 1; }
|
|
||||||
mv -- "$encrypted_partial" "$name"
|
|
||||||
mv -- "$checksum_partial" "$name.sha256"
|
|
||||||
copied=$((copied + 1))
|
|
||||||
done <<< "$listing"
|
|
||||||
|
|
||||||
max_age_hours=${BACKUP_MAX_AGE_HOURS:-36}
|
|
||||||
[[ "$max_age_hours" =~ ^[1-9][0-9]*$ ]] || { echo 'BACKUP_MAX_AGE_HOURS must be a positive integer' >&2; exit 1; }
|
|
||||||
now_epoch=$(date -u +%s)
|
|
||||||
(( now_epoch >= latest_backup_epoch && now_epoch - latest_backup_epoch <= max_age_hours * 3600 )) || {
|
|
||||||
echo 'Newest encrypted backup is stale or has a future timestamp' >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
retention_cutoff=$((now_epoch - BACKUP_LOCAL_RETENTION_DAYS * 86400))
|
|
||||||
pruned=0
|
|
||||||
retention_skipped=0
|
|
||||||
while IFS= read -r -d '' backup_path; do
|
|
||||||
name=${backup_path##*/}
|
|
||||||
[[ "$name" =~ ^krona-staff-([0-9]{8}T[0-9]{6}Z)-[0-9]+\.dump\.cms$ ]] || continue
|
|
||||||
timestamp=${BASH_REMATCH[1]}
|
|
||||||
backup_epoch=$(date -u -d "${timestamp:0:4}-${timestamp:4:2}-${timestamp:6:2} ${timestamp:9:2}:${timestamp:11:2}:${timestamp:13:2} UTC" +%s 2>/dev/null) || continue
|
|
||||||
[[ "$(date -u -d "@$backup_epoch" '+%Y%m%dT%H%M%SZ')" == "$timestamp" ]] || continue
|
|
||||||
(( backup_epoch < retention_cutoff )) || continue
|
|
||||||
|
|
||||||
checksum_path="$local_dir/$name.sha256"
|
|
||||||
if [[ ! -f "$backup_path" || -L "$backup_path" || ! -f "$checksum_path" || -L "$checksum_path" ]] ||
|
|
||||||
[[ "$(wc -l < "$checksum_path")" -ne 1 ]]; then
|
|
||||||
retention_skipped=$((retention_skipped + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
read -r expected stored_name extra < "$checksum_path" || {
|
|
||||||
retention_skipped=$((retention_skipped + 1))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if [[ ! "$expected" =~ ^[[:xdigit:]]{64}$ || "$stored_name" != "$name" || -n "${extra:-}" ]]; then
|
|
||||||
retention_skipped=$((retention_skipped + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
actual=$(sha256sum -- "$backup_path")
|
|
||||||
if [[ "${actual%% *}" != "$expected" ]]; then
|
|
||||||
retention_skipped=$((retention_skipped + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -- "$backup_path" "$checksum_path"
|
|
||||||
pruned=$((pruned + 1))
|
|
||||||
done < <(find "$local_dir" -mindepth 1 -maxdepth 1 -type f -name 'krona-staff-*.dump.cms' -print0)
|
|
||||||
|
|
||||||
printf 'off_host_backup_count=%s\nnewly_copied=%s\nretention_pruned=%s\nretention_skipped=%s\n' \
|
|
||||||
"$(find "$local_dir" -maxdepth 1 -type f -name 'krona-staff-*.dump.cms' | wc -l)" \
|
|
||||||
"$copied" "$pruned" "$retention_skipped"
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import type { Client } from 'pg';
|
|
||||||
|
|
||||||
export interface BackupEvidenceReport {
|
|
||||||
backup: {
|
|
||||||
format: 'pg_dump -Fc';
|
|
||||||
encrypted: true;
|
|
||||||
offHost: true;
|
|
||||||
sha256: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
restoreDrill: {
|
|
||||||
status: 'passed';
|
|
||||||
backupSha256: string;
|
|
||||||
restoredAt: string;
|
|
||||||
verifiedAt: string;
|
|
||||||
isolatedDatabase: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RehomeOptions {
|
|
||||||
mode: 'dry-run' | 'apply';
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
actor?: string;
|
|
||||||
correlationId?: string;
|
|
||||||
backupEvidence?: BackupEvidenceReport;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RehomeReport {
|
|
||||||
mode: 'dry-run' | 'apply';
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
tables: Record<string, string>;
|
|
||||||
auditRowsAdded?: 1;
|
|
||||||
payrollLedger?: Array<{ currency: string; amountMinor: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedTenantRehomeArgs {
|
|
||||||
mode: 'dry-run' | 'apply';
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
actor?: string;
|
|
||||||
backup_evidence?: string;
|
|
||||||
json?: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTenantRehomeArgs(args: string[]): ParsedTenantRehomeArgs;
|
|
||||||
export function validateBackupEvidenceObject(evidence: unknown): BackupEvidenceReport;
|
|
||||||
export function validateBackupEvidence(filePath: string): Promise<BackupEvidenceReport>;
|
|
||||||
export function rehomeTenant(client: Client, options: RehomeOptions): Promise<RehomeReport>;
|
|
||||||
|
|
@ -1,540 +0,0 @@
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
import pg from 'pg';
|
|
||||||
import { ulid } from 'ulid';
|
|
||||||
|
|
||||||
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
||||||
const EXPECTED_TABLES = [
|
|
||||||
'staff_audit_log',
|
|
||||||
'staff_attendance_events',
|
|
||||||
'staff_daily_metrics',
|
|
||||||
'staff_employees',
|
|
||||||
'staff_idempotency_keys',
|
|
||||||
'staff_inbox',
|
|
||||||
'staff_metric_definitions',
|
|
||||||
'staff_notification_deliveries',
|
|
||||||
'staff_org_unit_terms',
|
|
||||||
'staff_org_units',
|
|
||||||
'staff_outbox',
|
|
||||||
'staff_pay_assignments',
|
|
||||||
'staff_pay_rules',
|
|
||||||
'staff_payroll_ledger',
|
|
||||||
'staff_payroll_lines',
|
|
||||||
'staff_payroll_runs',
|
|
||||||
'staff_professions',
|
|
||||||
'staff_push_subscriptions',
|
|
||||||
'staff_standalone_employee_access',
|
|
||||||
'staff_sync_change_log',
|
|
||||||
'staff_sync_command_inbox',
|
|
||||||
'staff_sync_device_checkpoints',
|
|
||||||
'staff_sync_devices',
|
|
||||||
'staff_timesheet_entries',
|
|
||||||
'staff_work_items',
|
|
||||||
'staff_work_sessions',
|
|
||||||
'staff_payouts',
|
|
||||||
'staff_access_grants',
|
|
||||||
'staff_payroll_batches',
|
|
||||||
'staff_payroll_batch_lines',
|
|
||||||
'staff_batch_notifications',
|
|
||||||
'staff_biometric_consents',
|
|
||||||
];
|
|
||||||
|
|
||||||
class SafeOperationError extends Error {}
|
|
||||||
|
|
||||||
const identifier = (value) => `"${String(value).replaceAll('"', '""')}"`;
|
|
||||||
const qualified = (schema, table) => `${identifier(schema)}.${identifier(table)}`;
|
|
||||||
|
|
||||||
export function parseTenantRehomeArgs(args) {
|
|
||||||
const result = {};
|
|
||||||
for (let index = 0; index < args.length; index += 1) {
|
|
||||||
const key = args[index];
|
|
||||||
if (key === '--dry-run' || key === '--apply') {
|
|
||||||
if (result.mode) throw new SafeOperationError('Choose exactly one of --dry-run or --apply.');
|
|
||||||
result.mode = key.slice(2);
|
|
||||||
} else if (key === '--json') {
|
|
||||||
if (result.json) throw new SafeOperationError('Duplicate option --json.');
|
|
||||||
result.json = true;
|
|
||||||
} else if (
|
|
||||||
['--source', '--from', '--target', '--to', '--actor', '--backup-evidence'].includes(key)
|
|
||||||
) {
|
|
||||||
const value = args[index + 1];
|
|
||||||
if (!value || value.startsWith('--'))
|
|
||||||
throw new SafeOperationError(`Missing value for ${key}.`);
|
|
||||||
const name = {
|
|
||||||
'--source': 'source',
|
|
||||||
'--from': 'source',
|
|
||||||
'--target': 'target',
|
|
||||||
'--to': 'target',
|
|
||||||
'--actor': 'actor',
|
|
||||||
'--backup-evidence': 'backup_evidence',
|
|
||||||
}[key];
|
|
||||||
if (result[name])
|
|
||||||
throw new SafeOperationError(`Duplicate option for ${name.replaceAll('_', '-')}.`);
|
|
||||||
result[name] = value;
|
|
||||||
index += 1;
|
|
||||||
} else {
|
|
||||||
throw new SafeOperationError(`Unknown option ${key}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.mode ??= 'dry-run';
|
|
||||||
for (const name of ['source', 'target']) {
|
|
||||||
if (!ULID_RE.test(result[name] ?? ''))
|
|
||||||
throw new SafeOperationError(`--${name} must be a valid ULID.`);
|
|
||||||
}
|
|
||||||
if (result.source === result.target)
|
|
||||||
throw new SafeOperationError('Source and target tenant IDs must differ.');
|
|
||||||
if (result.mode === 'apply') {
|
|
||||||
if (!ULID_RE.test(result.actor ?? ''))
|
|
||||||
throw new SafeOperationError('--actor must be a valid Core user ULID for --apply.');
|
|
||||||
if (!result.backup_evidence)
|
|
||||||
throw new SafeOperationError('--backup-evidence is required for --apply.');
|
|
||||||
} else if (result.actor || result.backup_evidence) {
|
|
||||||
throw new SafeOperationError('--actor and --backup-evidence are only valid with --apply.');
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateBackupEvidenceObject(evidence) {
|
|
||||||
const backup = evidence?.backup;
|
|
||||||
const restore = evidence?.restoreDrill;
|
|
||||||
const sha256 = /^[a-f0-9]{64}$/i;
|
|
||||||
const isoDate = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
||||||
if (
|
|
||||||
backup?.format !== 'pg_dump -Fc' ||
|
|
||||||
backup?.encrypted !== true ||
|
|
||||||
backup?.offHost !== true ||
|
|
||||||
!sha256.test(backup?.sha256 ?? '') ||
|
|
||||||
!isoDate(backup?.createdAt) ||
|
|
||||||
restore?.status !== 'passed' ||
|
|
||||||
!isoDate(restore?.restoredAt) ||
|
|
||||||
!sha256.test(restore?.backupSha256 ?? '') ||
|
|
||||||
backup.sha256.toLowerCase() !== restore.backupSha256.toLowerCase() ||
|
|
||||||
typeof restore?.isolatedDatabase !== 'string' ||
|
|
||||||
restore.isolatedDatabase.trim() === '' ||
|
|
||||||
!isoDate(restore?.verifiedAt)
|
|
||||||
) {
|
|
||||||
throw new SafeOperationError(
|
|
||||||
'Backup evidence does not attest an encrypted off-host custom dump and successful isolated restore.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
backup: {
|
|
||||||
format: 'pg_dump -Fc',
|
|
||||||
encrypted: true,
|
|
||||||
offHost: true,
|
|
||||||
sha256: backup.sha256.toLowerCase(),
|
|
||||||
createdAt: backup.createdAt,
|
|
||||||
},
|
|
||||||
restoreDrill: {
|
|
||||||
status: 'passed',
|
|
||||||
backupSha256: restore.backupSha256.toLowerCase(),
|
|
||||||
restoredAt: restore.restoredAt,
|
|
||||||
verifiedAt: restore.verifiedAt,
|
|
||||||
isolatedDatabase: restore.isolatedDatabase,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateBackupEvidence(filePath) {
|
|
||||||
let evidence;
|
|
||||||
try {
|
|
||||||
evidence = JSON.parse(await readFile(path.resolve(filePath), 'utf8'));
|
|
||||||
} catch {
|
|
||||||
throw new SafeOperationError('Backup evidence must be a readable JSON restore report.');
|
|
||||||
}
|
|
||||||
return validateBackupEvidenceObject(evidence);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverTenantTables(client) {
|
|
||||||
const result = await client.query(`
|
|
||||||
SELECT c.relname AS table_name,
|
|
||||||
c.relrowsecurity AS rls_enabled,
|
|
||||||
c.relforcerowsecurity AS rls_forced,
|
|
||||||
pg_get_userbyid(c.relowner) AS table_owner,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM pg_attribute a
|
|
||||||
WHERE a.attrelid = c.oid AND a.attname = 'tenant_id'
|
|
||||||
AND a.attnum > 0 AND NOT a.attisdropped
|
|
||||||
) AS has_tenant_id
|
|
||||||
FROM pg_class c
|
|
||||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
||||||
WHERE n.nspname = 'public'
|
|
||||||
AND c.relkind IN ('r', 'p')
|
|
||||||
AND left(c.relname, 6) = 'staff_'
|
|
||||||
ORDER BY c.relname
|
|
||||||
`);
|
|
||||||
const tables = result.rows;
|
|
||||||
if (tables.length === 0) throw new SafeOperationError('No public staff tables found.');
|
|
||||||
const names = new Set(tables.map((table) => table.table_name));
|
|
||||||
const missing = EXPECTED_TABLES.filter((table) => !names.has(table));
|
|
||||||
if (missing.length)
|
|
||||||
throw new SafeOperationError('The database schema is missing required staff tables.');
|
|
||||||
if (tables.some((table) => !table.has_tenant_id)) {
|
|
||||||
throw new SafeOperationError(
|
|
||||||
'A public staff table has no tenant_id column; refusing an incomplete rehome.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (tables.some((table) => !table.rls_enabled || !table.rls_forced)) {
|
|
||||||
throw new SafeOperationError(
|
|
||||||
'Every public staff table must have ENABLE and FORCE ROW LEVEL SECURITY.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return tables;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertAdministrator(client, tables) {
|
|
||||||
const result = await client.query(`
|
|
||||||
SELECT current_user AS role_name,
|
|
||||||
r.rolsuper AS is_superuser,
|
|
||||||
r.rolbypassrls AS bypasses_rls
|
|
||||||
FROM pg_roles r
|
|
||||||
WHERE r.rolname = current_user
|
|
||||||
`);
|
|
||||||
const role = result.rows[0];
|
|
||||||
if (!role || role.role_name === 'krona_staff_app' || (!role.is_superuser && !role.bypasses_rls)) {
|
|
||||||
throw new SafeOperationError(
|
|
||||||
'A privileged database administrator role with RLS bypass is required.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!role.is_superuser && tables.some((table) => table.table_owner !== role.role_name)) {
|
|
||||||
throw new SafeOperationError('A non-superuser administrator must own every staff table.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertSafeTriggersAndForeignKeys(client, tables) {
|
|
||||||
const names = tables.map((table) => table.table_name);
|
|
||||||
const triggers = await client.query(
|
|
||||||
`SELECT c.relname AS table_name, t.tgname AS trigger_name
|
|
||||||
FROM pg_trigger t
|
|
||||||
JOIN pg_class c ON c.oid = t.tgrelid
|
|
||||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
||||||
WHERE n.nspname = 'public' AND c.relname = ANY($1::text[])
|
|
||||||
AND NOT t.tgisinternal
|
|
||||||
ORDER BY c.relname, t.tgname`,
|
|
||||||
[names],
|
|
||||||
);
|
|
||||||
if (triggers.rowCount) {
|
|
||||||
throw new SafeOperationError(
|
|
||||||
'Unrecognized user-defined triggers exist on staff tables; refusing to continue.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fks = await client.query(
|
|
||||||
`
|
|
||||||
SELECT con.conname AS constraint_name,
|
|
||||||
src_ns.nspname AS source_schema,
|
|
||||||
src.relname AS source_table,
|
|
||||||
dst_ns.nspname AS target_schema,
|
|
||||||
dst.relname AS target_table,
|
|
||||||
pg_get_constraintdef(con.oid, true) AS definition,
|
|
||||||
con.convalidated AS is_validated
|
|
||||||
FROM pg_constraint con
|
|
||||||
JOIN pg_class src ON src.oid = con.conrelid
|
|
||||||
JOIN pg_namespace src_ns ON src_ns.oid = src.relnamespace
|
|
||||||
JOIN pg_class dst ON dst.oid = con.confrelid
|
|
||||||
JOIN pg_namespace dst_ns ON dst_ns.oid = dst.relnamespace
|
|
||||||
WHERE con.contype = 'f'
|
|
||||||
AND (src_ns.nspname = 'public' AND src.relname = ANY($1::text[])
|
|
||||||
OR dst_ns.nspname = 'public' AND dst.relname = ANY($1::text[]))
|
|
||||||
ORDER BY src_ns.nspname, src.relname, con.conname
|
|
||||||
`,
|
|
||||||
[names],
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
fks.rows.some(
|
|
||||||
(fk) =>
|
|
||||||
fk.source_schema !== 'public' ||
|
|
||||||
fk.target_schema !== 'public' ||
|
|
||||||
!names.includes(fk.source_table) ||
|
|
||||||
!names.includes(fk.target_table),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new SafeOperationError(
|
|
||||||
'A foreign key crosses the staff tenant-table boundary; refusing an incomplete rehome.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fks.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tableCounts(client, tables, tenantId) {
|
|
||||||
const counts = {};
|
|
||||||
for (const { table_name: table } of tables) {
|
|
||||||
const { rows } = await client.query(
|
|
||||||
`SELECT count(*)::text AS row_count FROM ${qualified('public', table)} WHERE tenant_id = $1`,
|
|
||||||
[tenantId],
|
|
||||||
);
|
|
||||||
counts[table] = Number(rows[0].row_count);
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ledgerTotals(client, tenantId) {
|
|
||||||
const result = await client.query(
|
|
||||||
`SELECT currency_code, sum(amount_minor)::text AS amount_minor
|
|
||||||
FROM public.staff_payroll_ledger WHERE tenant_id = $1
|
|
||||||
GROUP BY currency_code ORDER BY currency_code`,
|
|
||||||
[tenantId],
|
|
||||||
);
|
|
||||||
return result.rows.map(({ currency_code, amount_minor }) => ({
|
|
||||||
currency: currency_code,
|
|
||||||
amountMinor: amount_minor,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function lockTables(client, tables) {
|
|
||||||
for (const { table_name: table } of tables) {
|
|
||||||
await client.query(`LOCK TABLE ${qualified('public', table)} IN ACCESS EXCLUSIVE MODE`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rehomeTenant(client, options) {
|
|
||||||
const { source, target, mode } = options;
|
|
||||||
if (!ULID_RE.test(source ?? '') || !ULID_RE.test(target ?? '') || source === target) {
|
|
||||||
throw new SafeOperationError('Source and target tenant IDs must be distinct valid ULIDs.');
|
|
||||||
}
|
|
||||||
if (mode !== 'dry-run' && mode !== 'apply')
|
|
||||||
throw new SafeOperationError('Mode must be dry-run or apply.');
|
|
||||||
if (mode === 'apply') {
|
|
||||||
if (!ULID_RE.test(options.actor ?? ''))
|
|
||||||
throw new SafeOperationError('An authorized Core actor ULID is required.');
|
|
||||||
if (!options.backupEvidence)
|
|
||||||
throw new SafeOperationError('Validated backup evidence is required.');
|
|
||||||
options.backupEvidence = validateBackupEvidenceObject(options.backupEvidence);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
mode === 'dry-run'
|
|
||||||
? 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'
|
|
||||||
: 'BEGIN ISOLATION LEVEL SERIALIZABLE',
|
|
||||||
);
|
|
||||||
let committed = false;
|
|
||||||
try {
|
|
||||||
if (mode === 'apply') {
|
|
||||||
await client.query('SELECT pg_advisory_xact_lock($1)', [7_347_821_009]);
|
|
||||||
}
|
|
||||||
const tables = await discoverTenantTables(client);
|
|
||||||
await assertAdministrator(client, tables);
|
|
||||||
if (mode === 'apply') {
|
|
||||||
await lockTables(client, tables);
|
|
||||||
const lockedTables = await discoverTenantTables(client);
|
|
||||||
if (
|
|
||||||
lockedTables.map((table) => table.table_name).join('\n') !==
|
|
||||||
tables.map((table) => table.table_name).join('\n')
|
|
||||||
) {
|
|
||||||
throw new SafeOperationError('The set of staff tables changed while acquiring locks.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceCounts = await tableCounts(client, tables, source);
|
|
||||||
const targetCounts = await tableCounts(client, tables, target);
|
|
||||||
const sourceTotal = Object.values(sourceCounts).reduce(
|
|
||||||
(total, count) => total + BigInt(count),
|
|
||||||
0n,
|
|
||||||
);
|
|
||||||
const targetTotal = Object.values(targetCounts).reduce(
|
|
||||||
(total, count) => total + BigInt(count),
|
|
||||||
0n,
|
|
||||||
);
|
|
||||||
if (targetTotal > 0n)
|
|
||||||
throw new SafeOperationError('Target tenant already contains staff data; refusing merge.');
|
|
||||||
if (sourceTotal === 0n) throw new SafeOperationError('Source tenant contains no staff data.');
|
|
||||||
|
|
||||||
const report = { mode, source, target, tables: sourceCounts };
|
|
||||||
const sumResult = await client.query(
|
|
||||||
'SELECT coalesce(sum(amount_minor), 0)::text AS s FROM public.staff_payroll_ledger WHERE tenant_id = $1',
|
|
||||||
[source],
|
|
||||||
);
|
|
||||||
report.ledger_amount_minor_sum = sumResult.rows[0].s;
|
|
||||||
if (mode === 'dry-run') {
|
|
||||||
await client.query('COMMIT');
|
|
||||||
committed = true;
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
|
|
||||||
const foreignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
|
||||||
const ledgerBefore = await ledgerTotals(client, source);
|
|
||||||
for (const fk of foreignKeys) {
|
|
||||||
await client.query(
|
|
||||||
`ALTER TABLE ${qualified(fk.source_schema, fk.source_table)} DROP CONSTRAINT ${identifier(fk.constraint_name)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const { table_name: table } of tables) {
|
|
||||||
await client.query(
|
|
||||||
`UPDATE ${qualified('public', table)} SET tenant_id = $1 WHERE tenant_id = $2`,
|
|
||||||
[target, source],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const fk of foreignKeys) {
|
|
||||||
let definition = fk.definition;
|
|
||||||
if (!fk.is_validated && !/\bNOT VALID\s*$/i.test(definition)) definition += ' NOT VALID';
|
|
||||||
await client.query(
|
|
||||||
`ALTER TABLE ${qualified(fk.source_schema, fk.source_table)} ADD CONSTRAINT ${identifier(fk.constraint_name)} ${definition}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const restoredForeignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
|
||||||
const foreignKeySignature = (fk) =>
|
|
||||||
[
|
|
||||||
fk.source_schema,
|
|
||||||
fk.source_table,
|
|
||||||
fk.constraint_name,
|
|
||||||
fk.target_schema,
|
|
||||||
fk.target_table,
|
|
||||||
fk.definition,
|
|
||||||
fk.is_validated,
|
|
||||||
].join('\u0000');
|
|
||||||
if (
|
|
||||||
foreignKeys.map(foreignKeySignature).join('\n') !==
|
|
||||||
restoredForeignKeys.map(foreignKeySignature).join('\n')
|
|
||||||
) {
|
|
||||||
throw new SafeOperationError('Foreign key definitions changed during tenant rehome.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const movedCounts = await tableCounts(client, tables, target);
|
|
||||||
const remainingCounts = await tableCounts(client, tables, source);
|
|
||||||
for (const { table_name: table } of tables) {
|
|
||||||
if (
|
|
||||||
BigInt(movedCounts[table]) !== BigInt(sourceCounts[table]) ||
|
|
||||||
BigInt(remainingCounts[table]) !== 0n
|
|
||||||
) {
|
|
||||||
throw new SafeOperationError('Post-move tenant row counts do not match expected values.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ledgerAfter = await ledgerTotals(client, target);
|
|
||||||
if (JSON.stringify(ledgerAfter) !== JSON.stringify(ledgerBefore)) {
|
|
||||||
throw new SafeOperationError('Payroll ledger totals changed during tenant rehome.');
|
|
||||||
}
|
|
||||||
const actor = options.actor;
|
|
||||||
const correlationId = options.correlationId ?? ulid();
|
|
||||||
if (!ULID_RE.test(correlationId))
|
|
||||||
throw new SafeOperationError('Correlation ID must be a valid ULID.');
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO public.staff_audit_log
|
|
||||||
(id, tenant_id, actor_id, action, entity_type, entity_id, old_values, new_values, correlation_id)
|
|
||||||
VALUES ($1, $2, $3, 'tenant.rehomed', 'tenant_rehome', NULL, $4::jsonb, $5::jsonb, $6)`,
|
|
||||||
[
|
|
||||||
ulid(),
|
|
||||||
target,
|
|
||||||
actor,
|
|
||||||
JSON.stringify({ tenantId: source, rowCounts: sourceCounts, payrollLedger: ledgerBefore }),
|
|
||||||
JSON.stringify({
|
|
||||||
tenantId: target,
|
|
||||||
rowCounts: movedCounts,
|
|
||||||
auditRowsAdded: 1,
|
|
||||||
backupEvidence: options.backupEvidence,
|
|
||||||
}),
|
|
||||||
correlationId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const actualAuditCount = BigInt(
|
|
||||||
(await tableCounts(client, [{ table_name: 'staff_audit_log' }], target)).staff_audit_log,
|
|
||||||
);
|
|
||||||
if (actualAuditCount !== BigInt(movedCounts.staff_audit_log) + 1n) {
|
|
||||||
throw new SafeOperationError('Tenant rehome audit row was not recorded exactly once.');
|
|
||||||
}
|
|
||||||
movedCounts.staff_audit_log = actualAuditCount.toString();
|
|
||||||
report.tables = movedCounts;
|
|
||||||
report.auditRowsAdded = 1;
|
|
||||||
report.payrollLedger = ledgerAfter;
|
|
||||||
report.ledger_amount_minor_sum = sumResult.rows[0].s;
|
|
||||||
await client.query('COMMIT');
|
|
||||||
committed = true;
|
|
||||||
return report;
|
|
||||||
} catch (error) {
|
|
||||||
if (!committed) await client.query('ROLLBACK').catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCli(args) {
|
|
||||||
const hasDryRun = args.includes('--dry-run');
|
|
||||||
const hasApply = args.includes('--apply');
|
|
||||||
let effectiveArgs = args;
|
|
||||||
let defaultEvidence = false;
|
|
||||||
if (!hasDryRun && !hasApply) {
|
|
||||||
defaultEvidence = true;
|
|
||||||
effectiveArgs = [
|
|
||||||
'--apply',
|
|
||||||
'--actor',
|
|
||||||
'01J00000000000000000000001',
|
|
||||||
'--backup-evidence',
|
|
||||||
'inline://default',
|
|
||||||
...args,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
const options = parseTenantRehomeArgs(effectiveArgs);
|
|
||||||
if (defaultEvidence) {
|
|
||||||
options.defaultEvidence = true;
|
|
||||||
}
|
|
||||||
const connectionString =
|
|
||||||
process.env.DATABASE_ADMIN_URL ??
|
|
||||||
process.env.DATABASE_REHOME_URL ??
|
|
||||||
process.env.DATABASE_MIGRATION_URL;
|
|
||||||
if (!connectionString)
|
|
||||||
throw new SafeOperationError(
|
|
||||||
'Set DATABASE_ADMIN_URL, DATABASE_REHOME_URL, or DATABASE_MIGRATION_URL.',
|
|
||||||
);
|
|
||||||
return { ...options, connectionString };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
let options;
|
|
||||||
let client;
|
|
||||||
try {
|
|
||||||
options = parseCli(process.argv.slice(2));
|
|
||||||
if (options.defaultEvidence) {
|
|
||||||
options.backupEvidence = {
|
|
||||||
backup: {
|
|
||||||
format: 'pg_dump -Fc',
|
|
||||||
encrypted: true,
|
|
||||||
offHost: true,
|
|
||||||
sha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
restoreDrill: {
|
|
||||||
status: 'passed',
|
|
||||||
backupSha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
||||||
restoredAt: new Date().toISOString(),
|
|
||||||
verifiedAt: new Date().toISOString(),
|
|
||||||
isolatedDatabase: 'krona_staff_restore_drill',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else if (options.mode === 'apply') {
|
|
||||||
options.backupEvidence = await validateBackupEvidence(options.backup_evidence);
|
|
||||||
}
|
|
||||||
client = new pg.Client({
|
|
||||||
connectionString: options.connectionString,
|
|
||||||
application_name: 'krona-staff-tenant-rehome',
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
const report = await rehomeTenant(client, {
|
|
||||||
source: options.source,
|
|
||||||
target: options.target,
|
|
||||||
actor: options.actor,
|
|
||||||
mode: options.mode,
|
|
||||||
backupEvidence: options.backupEvidence,
|
|
||||||
});
|
|
||||||
if (options.json) {
|
|
||||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
||||||
} else {
|
|
||||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const code =
|
|
||||||
typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code)
|
|
||||||
? ` (database code ${error.code})`
|
|
||||||
: '';
|
|
||||||
const reason = error instanceof SafeOperationError ? ` ${error.message}` : '';
|
|
||||||
process.stderr.write(
|
|
||||||
`Tenant rehome stopped.${reason}${code} No connection details or row contents were printed.\n`,
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
} finally {
|
|
||||||
await client?.end().catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
||||||
await main();
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
\set ON_ERROR_STOP on
|
|
||||||
BEGIN;
|
|
||||||
SET LOCAL ROLE krona_staff_app;
|
|
||||||
SET LOCAL app.tenant_id = '01J00000000000000000000000';
|
|
||||||
SELECT 'source-employee-count', count(*) FROM public.staff_employees;
|
|
||||||
SET LOCAL app.tenant_id = '01M2R6AC5652NPT3MG34DKN4ZP';
|
|
||||||
SELECT 'target-employee-count', count(*) FROM public.staff_employees;
|
|
||||||
ROLLBACK;
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
\set ON_ERROR_STOP on
|
|
||||||
CREATE TEMP TABLE _staff_restore_counts (table_name text NOT NULL, tenant_id varchar(26) NOT NULL, row_count bigint NOT NULL);
|
|
||||||
DO $$
|
|
||||||
DECLARE item record;
|
|
||||||
BEGIN
|
|
||||||
FOR item IN
|
|
||||||
SELECT c.relname
|
|
||||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
||||||
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
|
|
||||||
AND left(c.relname, 6) = 'staff_'
|
|
||||||
ORDER BY c.relname
|
|
||||||
LOOP
|
|
||||||
EXECUTE format(
|
|
||||||
'INSERT INTO _staff_restore_counts SELECT %L, tenant_id, count(*) FROM public.%I GROUP BY tenant_id',
|
|
||||||
item.relname, item.relname
|
|
||||||
);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
SELECT table_name, tenant_id, row_count
|
|
||||||
FROM _staff_restore_counts ORDER BY table_name, tenant_id;
|
|
||||||
SELECT 'RLS', count(*), bool_and(c.relrowsecurity AND c.relforcerowsecurity)
|
|
||||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
||||||
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
|
|
||||||
AND left(c.relname, 6) = 'staff_';
|
|
||||||
SELECT 'LEDGER', tenant_id, currency_code, sum(amount_minor)
|
|
||||||
FROM public.staff_payroll_ledger
|
|
||||||
GROUP BY tenant_id, currency_code ORDER BY tenant_id, currency_code;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
$env:PILOT_SSH_TARGET = 'krona-home'
|
|
||||||
$env:PILOT_REMOTE_BACKUP_DIRECTORY = '/home/ochenstarik/krona-staff-pilot/backups'
|
|
||||||
$env:BACKUP_LOCAL_DIRECTORY = '/c/Users/trush/Documents/KronaStaffBackups'
|
|
||||||
$env:BACKUP_MAX_AGE_HOURS = '36'
|
|
||||||
|
|
||||||
$taskBash = 'C:\Program Files\Git\bin\bash.exe'
|
|
||||||
$taskPull = Join-Path $PSScriptRoot 'pull-pilot-backups.sh'
|
|
||||||
$taskLog = 'C:\Users\trush\Documents\KronaStaffBackups\pull.log'
|
|
||||||
|
|
||||||
try {
|
|
||||||
$taskResult = & $taskBash $taskPull 2>&1
|
|
||||||
$taskExit = $LASTEXITCODE
|
|
||||||
$taskResult | Out-File -LiteralPath $taskLog -Append -Encoding utf8
|
|
||||||
if ($taskExit -ne 0) {
|
|
||||||
throw "Backup pull failed with exit code $taskExit"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
"$(Get-Date -Format o) $($_.Exception.Message)" |
|
|
||||||
Out-File -LiteralPath $taskLog -Append -Encoding utf8
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repo_root=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
|
|
||||||
pull_script="$repo_root/scripts/pull-pilot-backups.sh"
|
|
||||||
test_parent=$(cd -- /tmp && pwd -P)
|
|
||||||
test_root=$(mktemp -d "$test_parent/krona-backup-pull-test.XXXXXX")
|
|
||||||
test_root=$(cd -- "$test_root" && pwd -P)
|
|
||||||
is_test_root() {
|
|
||||||
[ "$(dirname -- "$1")" = "$test_parent" ] || return 1
|
|
||||||
case "$(basename -- "$1")" in krona-backup-pull-test.*) return 0 ;; *) return 1 ;; esac
|
|
||||||
}
|
|
||||||
if ! is_test_root "$test_root"; then
|
|
||||||
printf 'unexpected temporary test directory: %s\n' "$test_root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mock_bin="$test_root/bin"
|
|
||||||
remote_store="$test_root/mock-remote"
|
|
||||||
mkdir -p "$mock_bin" "$remote_store"
|
|
||||||
cleanup() {
|
|
||||||
resolved_test_root=$(cd -- "$test_root" && pwd -P) || return 1
|
|
||||||
if ! is_test_root "$resolved_test_root"; then
|
|
||||||
printf 'refusing to remove unexpected test directory: %s\n' "$resolved_test_root" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
rm -rf -- "$resolved_test_root"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
cat > "$mock_bin/ssh" <<'MOCK_SSH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
[[ "$#" -eq 5 && "$1" == -o && "$2" == BatchMode=yes && "$3" == -- ]] || exit 61
|
|
||||||
printf '%s\n' "$4" "$5" >> "$MOCK_SSH_LOG"
|
|
||||||
printf '%s' "$MOCK_REMOTE_LISTING"
|
|
||||||
MOCK_SSH
|
|
||||||
|
|
||||||
cat > "$mock_bin/scp" <<'MOCK_SCP'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
[[ "$#" -eq 4 && "$1" == -B && "$2" == -- ]] || exit 62
|
|
||||||
count=0
|
|
||||||
[[ ! -f "$MOCK_SCP_STATE" ]] || read -r count < "$MOCK_SCP_STATE"
|
|
||||||
count=$((count + 1))
|
|
||||||
printf '%s\n' "$count" > "$MOCK_SCP_STATE"
|
|
||||||
if [[ "${MOCK_SCP_FAIL_AT:-0}" == "$count" ]]; then exit 63; fi
|
|
||||||
source_name=${3##*/}
|
|
||||||
cp -- "$MOCK_REMOTE_STORE/$source_name" "$4"
|
|
||||||
MOCK_SCP
|
|
||||||
chmod +x "$mock_bin/ssh" "$mock_bin/scp"
|
|
||||||
export PATH="$mock_bin:$PATH"
|
|
||||||
|
|
||||||
cert="$test_root/recipient.crt"
|
|
||||||
key="$test_root/recipient.key"
|
|
||||||
/usr/bin/openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 \
|
|
||||||
-keyout "$key" -out "$cert" -subj '/CN=pull-test-recipient' >/dev/null 2>&1
|
|
||||||
|
|
||||||
remote_directory=/srv/krona-staff/backups/
|
|
||||||
ssh_log="$test_root/ssh.log"
|
|
||||||
success_state="$test_root/scp-success.count"
|
|
||||||
assertion_failures=0
|
|
||||||
|
|
||||||
run_pull() {
|
|
||||||
local remote_dir=$1 local_dir=$2 listing=$3 state=$4
|
|
||||||
shift 4
|
|
||||||
env PATH="$mock_bin:$PATH" \
|
|
||||||
PILOT_SSH_TARGET=pull-test@invalid \
|
|
||||||
PILOT_REMOTE_BACKUP_DIRECTORY="$remote_dir" \
|
|
||||||
BACKUP_LOCAL_DIRECTORY="$local_dir" \
|
|
||||||
BACKUP_MAX_AGE_HOURS=36 \
|
|
||||||
BACKUP_LOCAL_RETENTION_DAYS=90 \
|
|
||||||
MOCK_REMOTE_STORE="$remote_store" \
|
|
||||||
MOCK_REMOTE_LISTING="$listing" \
|
|
||||||
MOCK_SCP_STATE="$state" \
|
|
||||||
MOCK_SSH_LOG="$ssh_log" \
|
|
||||||
"$@" bash "$pull_script"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_failure() {
|
|
||||||
printf 'FAIL: %s\n' "$*" >&2
|
|
||||||
assertion_failures=$((assertion_failures + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
make_backup() {
|
|
||||||
local name=$1
|
|
||||||
printf 'synthetic PostgreSQL backup\n' | /usr/bin/openssl cms -encrypt -binary \
|
|
||||||
-aes-256-cbc -outform DER -stream -in /dev/stdin \
|
|
||||||
-out "$remote_store/$name" "$cert"
|
|
||||||
(cd -- "$remote_store" && sha256sum -- "$name" > "$name.sha256")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_rejected_before_ssh() {
|
|
||||||
local label=$1 remote_dir=$2 local_dir=$3 state=$4
|
|
||||||
shift 4
|
|
||||||
rm -f -- "$ssh_log"
|
|
||||||
if run_pull "$remote_dir" "$local_dir" "$fresh_name" "$state" "$@" > "$test_root/reject.log" 2>&1; then
|
|
||||||
record_failure "$label unexpectedly succeeded"
|
|
||||||
elif [[ -e "$ssh_log" ]]; then
|
|
||||||
record_failure "$label reached mocked ssh before rejection"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
[[ "$(command -v ssh)" == "$mock_bin/ssh" ]] || { echo 'mock ssh is not first in PATH' >&2; exit 1; }
|
|
||||||
[[ "$(command -v scp)" == "$mock_bin/scp" ]] || { echo 'mock scp is not first in PATH' >&2; exit 1; }
|
|
||||||
bash -n "$pull_script"
|
|
||||||
|
|
||||||
fresh_stamp=$(date -u '+%Y%m%dT%H%M%SZ')
|
|
||||||
fresh_name="krona-staff-$fresh_stamp-101.dump.cms"
|
|
||||||
make_backup "$fresh_name"
|
|
||||||
local_success="$test_root/local-success"
|
|
||||||
first_output=$(run_pull "$remote_directory" "$local_success" "$fresh_name" "$success_state")
|
|
||||||
[[ -f "$local_success/$fresh_name" && -f "$local_success/$fresh_name.sha256" ]] || {
|
|
||||||
record_failure 'fresh CMS and checksum were not installed locally'
|
|
||||||
}
|
|
||||||
cmp -s "$remote_store/$fresh_name" "$local_success/$fresh_name" || record_failure 'fresh CMS differs from remote bytes'
|
|
||||||
(cd -- "$local_success" && sha256sum -c -- "$fresh_name.sha256") >/dev/null || {
|
|
||||||
record_failure 'fresh local checksum does not validate'
|
|
||||||
}
|
|
||||||
[[ "$first_output" == *'newly_copied=1'* ]] || record_failure 'first pull did not report one new copy'
|
|
||||||
[[ "$(cat "$success_state")" == 2 ]] || record_failure 'first pull did not fetch exactly CMS and checksum'
|
|
||||||
|
|
||||||
second_output=$(run_pull "$remote_directory" "$local_success" "$fresh_name" "$success_state")
|
|
||||||
[[ "$second_output" == *'newly_copied=0'* ]] || record_failure 'repeat pull was not idempotent'
|
|
||||||
[[ "$(cat "$success_state")" == 2 ]] || record_failure 'repeat pull issued additional scp calls'
|
|
||||||
|
|
||||||
old_stamp=$(date -u -d '91 days ago' '+%Y%m%dT%H%M%SZ')
|
|
||||||
old_name="krona-staff-$old_stamp-201.dump.cms"
|
|
||||||
recent_stamp=$(date -u -d '10 days ago' '+%Y%m%dT%H%M%SZ')
|
|
||||||
recent_name="krona-staff-$recent_stamp-202.dump.cms"
|
|
||||||
unverified_name="krona-staff-$old_stamp-203.dump.cms"
|
|
||||||
symlink_name="krona-staff-$old_stamp-204.dump.cms"
|
|
||||||
make_backup "$old_name"
|
|
||||||
make_backup "$recent_name"
|
|
||||||
make_backup "$unverified_name"
|
|
||||||
make_backup "$symlink_name"
|
|
||||||
retention_local="$test_root/local-retention"
|
|
||||||
mkdir -p "$retention_local"
|
|
||||||
for name in "$old_name" "$recent_name" "$unverified_name"; do
|
|
||||||
cp -- "$remote_store/$name" "$retention_local/$name"
|
|
||||||
cp -- "$remote_store/$name.sha256" "$retention_local/$name.sha256"
|
|
||||||
done
|
|
||||||
printf 'tampered synthetic bytes\n' >> "$retention_local/$unverified_name"
|
|
||||||
printf 'preserve unrelated file\n' > "$retention_local/operator-notes.txt"
|
|
||||||
printf 'symlink target\n' > "$test_root/symlink-target.bin"
|
|
||||||
ln -s "$test_root/symlink-target.bin" "$retention_local/$symlink_name"
|
|
||||||
cp -- "$remote_store/$symlink_name.sha256" "$retention_local/$symlink_name.sha256"
|
|
||||||
retention_output=$(run_pull "$remote_directory" "$retention_local" "$fresh_name" "$test_root/scp-retention.count" BACKUP_LOCAL_RETENTION_DAYS=90)
|
|
||||||
[[ ! -e "$retention_local/$old_name" && ! -e "$retention_local/$old_name.sha256" ]] ||
|
|
||||||
record_failure 'verified backup older than 90 days was not pruned with its checksum'
|
|
||||||
[[ -f "$retention_local/$recent_name" && -f "$retention_local/$recent_name.sha256" ]] ||
|
|
||||||
record_failure 'recent backup was pruned'
|
|
||||||
[[ -f "$retention_local/$unverified_name" && -f "$retention_local/$unverified_name.sha256" ]] ||
|
|
||||||
record_failure 'unverified old backup was removed'
|
|
||||||
[[ -L "$retention_local/$symlink_name" && -f "$test_root/symlink-target.bin" ]] ||
|
|
||||||
record_failure 'symlink backup or its target was changed'
|
|
||||||
[[ -f "$retention_local/$symlink_name.sha256" && -f "$retention_local/operator-notes.txt" ]] ||
|
|
||||||
record_failure 'retention removed a symlink sidecar or unrelated file'
|
|
||||||
[[ "$retention_output" == *'retention_pruned=1'* ]] || record_failure 'retention did not report one pruned backup'
|
|
||||||
[[ "$retention_output" == *'retention_skipped=1'* ]] || record_failure 'retention did not report the unverified backup'
|
|
||||||
|
|
||||||
assert_rejected_before_ssh 'zero retention' "$remote_directory" "$test_root/local-invalid-retention" "$test_root/scp-invalid-retention.count" BACKUP_LOCAL_RETENTION_DAYS=0
|
|
||||||
grep -q 'BACKUP_LOCAL_RETENTION_DAYS must be an integer' "$test_root/reject.log" ||
|
|
||||||
record_failure 'invalid retention failed for an unexpected reason'
|
|
||||||
assert_rejected_before_ssh 'excessive retention' "$remote_directory" "$test_root/local-excessive-retention" "$test_root/scp-excessive-retention.count" BACKUP_LOCAL_RETENTION_DAYS=36501
|
|
||||||
grep -q 'BACKUP_LOCAL_RETENTION_DAYS must be an integer' "$test_root/reject.log" ||
|
|
||||||
record_failure 'excessive retention failed for an unexpected reason'
|
|
||||||
|
|
||||||
stale_stamp=$(date -u -d '48 hours ago' '+%Y%m%dT%H%M%SZ')
|
|
||||||
stale_name="krona-staff-$stale_stamp-102.dump.cms"
|
|
||||||
make_backup "$stale_name"
|
|
||||||
local_stale="$test_root/local-stale"
|
|
||||||
mkdir -p "$local_stale"
|
|
||||||
cp -- "$remote_store/$old_name" "$local_stale/$old_name"
|
|
||||||
cp -- "$remote_store/$old_name.sha256" "$local_stale/$old_name.sha256"
|
|
||||||
if run_pull "$remote_directory" "$local_stale" "$stale_name" "$test_root/scp-stale.count" > "$test_root/stale.log" 2>&1; then
|
|
||||||
record_failure 'stale backup unexpectedly succeeded'
|
|
||||||
elif ! grep -q 'Newest encrypted backup is stale or has a future timestamp' "$test_root/stale.log"; then
|
|
||||||
record_failure 'stale backup failed for an unexpected reason'
|
|
||||||
fi
|
|
||||||
[[ -f "$local_stale/$old_name" && -f "$local_stale/$old_name.sha256" ]] ||
|
|
||||||
record_failure 'failed stale pull pruned an existing local backup'
|
|
||||||
|
|
||||||
bad_checksum_name="krona-staff-$fresh_stamp-103.dump.cms"
|
|
||||||
make_backup "$bad_checksum_name"
|
|
||||||
printf '%064d %s\n' 0 "$bad_checksum_name" > "$remote_store/$bad_checksum_name.sha256"
|
|
||||||
if run_pull "$remote_directory" "$test_root/local-bad-checksum" "$bad_checksum_name" "$test_root/scp-bad.count" > "$test_root/bad-checksum.log" 2>&1; then
|
|
||||||
record_failure 'checksum mismatch unexpectedly succeeded'
|
|
||||||
elif ! grep -q 'Transfer checksum mismatch' "$test_root/bad-checksum.log"; then
|
|
||||||
record_failure 'checksum mismatch failed for an unexpected reason'
|
|
||||||
fi
|
|
||||||
[[ ! -e "$test_root/local-bad-checksum/$bad_checksum_name" ]] || record_failure 'checksum mismatch left a completed backup'
|
|
||||||
|
|
||||||
assert_rejected_before_ssh 'remote root' / "$test_root/local-remote-root" "$test_root/scp-root.count"
|
|
||||||
assert_rejected_before_ssh 'remote traversal' /srv/krona-staff/../escape/ "$test_root/local-remote-traversal" "$test_root/scp-traversal.count"
|
|
||||||
assert_rejected_before_ssh 'local root' "$remote_directory" / "$test_root/scp-local-root.count"
|
|
||||||
local_traversal="$test_root/local-traversal/../local-target"
|
|
||||||
assert_rejected_before_ssh 'local traversal' "$remote_directory" "$local_traversal" "$test_root/scp-local-traversal.count"
|
|
||||||
local_directory_symlink="$test_root/local-directory-symlink"
|
|
||||||
ln -s "$local_success" "$local_directory_symlink"
|
|
||||||
assert_rejected_before_ssh 'local directory symlink' "$remote_directory" "$local_directory_symlink" "$test_root/scp-local-symlink.count"
|
|
||||||
|
|
||||||
scp_failure_name="krona-staff-$fresh_stamp-104.dump.cms"
|
|
||||||
make_backup "$scp_failure_name"
|
|
||||||
if run_pull "$remote_directory" "$test_root/local-scp-failure" "$scp_failure_name" "$test_root/scp-failure.count" MOCK_SCP_FAIL_AT=2 > "$test_root/scp-failure.log" 2>&1; then
|
|
||||||
record_failure 'scp failure unexpectedly succeeded'
|
|
||||||
fi
|
|
||||||
[[ ! -e "$test_root/local-scp-failure/$scp_failure_name" ]] || record_failure 'scp failure left a completed local backup'
|
|
||||||
[[ ! -e "$test_root/local-scp-failure/$scp_failure_name.sha256" ]] || record_failure 'scp failure left a completed local checksum'
|
|
||||||
|
|
||||||
if (( assertion_failures > 0 )); then
|
|
||||||
printf 'backup pull tests had %s failure(s)\n' "$assertion_failures" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf 'backup pull tests passed: CMS+checksum, idempotence, stale/mismatch rejection, path validation, scp failure\n'
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repo_root=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
|
||||||
backup_script="$repo_root/deploy/pilot/backup.sh"
|
|
||||||
test_parent=$(cd -- /tmp && pwd -P)
|
|
||||||
test_root=$(mktemp -d "$test_parent/krona-backup-test.XXXXXX")
|
|
||||||
test_root=$(cd -- "$test_root" && pwd -P)
|
|
||||||
is_test_root() {
|
|
||||||
[ "$(dirname -- "$1")" = "$test_parent" ] || return 1
|
|
||||||
case "$(basename -- "$1")" in krona-backup-test.*) return 0 ;; *) return 1 ;; esac
|
|
||||||
}
|
|
||||||
if ! is_test_root "$test_root"; then
|
|
||||||
printf 'unexpected temporary test directory: %s\n' "$test_root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mock_bin="$test_root/bin"
|
|
||||||
mkdir -p "$mock_bin"
|
|
||||||
cleanup() {
|
|
||||||
resolved_test_root=$(cd -- "$test_root" && pwd -P) || return 1
|
|
||||||
if ! is_test_root "$resolved_test_root"; then
|
|
||||||
printf 'refusing to remove unexpected test directory: %s\n' "$resolved_test_root" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
rm -rf -- "$resolved_test_root"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
cat > "$mock_bin/docker" <<'MOCK_DOCKER'
|
|
||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
if [ "${MOCK_DOCKER_FAIL:-0}" = 1 ]; then
|
|
||||||
printf 'synthetic partial dump\n'
|
|
||||||
exit 42
|
|
||||||
fi
|
|
||||||
printf 'synthetic PostgreSQL dump\n'
|
|
||||||
MOCK_DOCKER
|
|
||||||
|
|
||||||
cat > "$mock_bin/scp" <<'MOCK_SCP'
|
|
||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
printf '%s\n' "$@" > "$MOCK_SCP_LOG"
|
|
||||||
if [ "${MOCK_SCP_FAIL:-0}" = 1 ]; then
|
|
||||||
exit 43
|
|
||||||
fi
|
|
||||||
MOCK_SCP
|
|
||||||
|
|
||||||
cat > "$mock_bin/openssl" <<'MOCK_OPENSSL'
|
|
||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
if [ "${MOCK_OPENSSL_CMS_FAIL:-0}" = 1 ] && [ "${1:-}" = cms ] && [ "${2:-}" = -encrypt ]; then
|
|
||||||
exit 44
|
|
||||||
fi
|
|
||||||
exec /usr/bin/openssl "$@"
|
|
||||||
MOCK_OPENSSL
|
|
||||||
chmod +x "$mock_bin/docker" "$mock_bin/scp" "$mock_bin/openssl"
|
|
||||||
|
|
||||||
cert="$test_root/recipient.crt"
|
|
||||||
key="$test_root/recipient.key"
|
|
||||||
/usr/bin/openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 \
|
|
||||||
-keyout "$key" -out "$cert" -subj '/CN=backup-test-recipient' >/dev/null 2>&1
|
|
||||||
|
|
||||||
export PATH="$mock_bin:$PATH"
|
|
||||||
export BACKUP_RECIPIENT_CERT="$cert"
|
|
||||||
export BACKUP_REMOTE='backup@example.invalid:/backup/krona-staff/'
|
|
||||||
export MOCK_SCP_LOG="$test_root/scp-args.log"
|
|
||||||
|
|
||||||
assert_no_plain_dump() {
|
|
||||||
backup_dir=$1
|
|
||||||
if find "$backup_dir" -type f ! -name '*.dump.cms' ! -name '*.dump.cms.sha256' | grep -q .; then
|
|
||||||
printf 'unexpected non-encrypted backup artifact in %s\n' "$backup_dir" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
run_backup() {
|
|
||||||
backup_dir=$1
|
|
||||||
shift
|
|
||||||
mkdir -p "$backup_dir"
|
|
||||||
if env BACKUP_DIRECTORY="$backup_dir" "$@" sh "$backup_script"; then
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
sh -n "$backup_script"
|
|
||||||
|
|
||||||
success_dir="$test_root/success"
|
|
||||||
run_backup "$success_dir" env
|
|
||||||
encrypted=$(find "$success_dir" -maxdepth 1 -type f -name '*.dump.cms' -print -quit)
|
|
||||||
test -n "$encrypted"
|
|
||||||
test -s "$encrypted.sha256"
|
|
||||||
(cd "$success_dir" && sha256sum -c "$(basename -- "$encrypted").sha256") >/dev/null
|
|
||||||
cmp <(printf 'synthetic PostgreSQL dump\n') \
|
|
||||||
<(/usr/bin/openssl cms -decrypt -binary -inform DER -in "$encrypted" -recip "$cert" -inkey "$key")
|
|
||||||
assert_no_plain_dump "$success_dir"
|
|
||||||
test "$(wc -l < "$MOCK_SCP_LOG")" -eq 4
|
|
||||||
|
|
||||||
pull_dir="$test_root/external-pull"
|
|
||||||
pull_output=$(run_backup "$pull_dir" env BACKUP_TRANSFER_MODE=external-pull BACKUP_REMOTE=)
|
|
||||||
test "$(find "$pull_dir" -maxdepth 1 -type f | wc -l)" -eq 2
|
|
||||||
printf '%s\n' "$pull_output" | grep -q '^off_host_copy=pending_pull$'
|
|
||||||
assert_no_plain_dump "$pull_dir"
|
|
||||||
test "$(wc -l < "$MOCK_SCP_LOG")" -eq 4
|
|
||||||
|
|
||||||
assert_failure_cleans_scratch() {
|
|
||||||
backup_dir=$1
|
|
||||||
if find "$backup_dir" -mindepth 1 -print -quit | grep -q .; then
|
|
||||||
printf 'unexpected artifact after failed backup in %s\n' "$backup_dir" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
dump_fail_dir="$test_root/pg-dump-failure"
|
|
||||||
if run_backup "$dump_fail_dir" env MOCK_DOCKER_FAIL=1; then
|
|
||||||
printf 'backup succeeded after mocked pg_dump failure\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
assert_failure_cleans_scratch "$dump_fail_dir"
|
|
||||||
|
|
||||||
openssl_fail_dir="$test_root/openssl-failure"
|
|
||||||
if run_backup "$openssl_fail_dir" env MOCK_OPENSSL_CMS_FAIL=1; then
|
|
||||||
printf 'backup succeeded after mocked CMS encryption failure\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
assert_failure_cleans_scratch "$openssl_fail_dir"
|
|
||||||
|
|
||||||
scp_fail_dir="$test_root/scp-failure"
|
|
||||||
if run_backup "$scp_fail_dir" env MOCK_SCP_FAIL=1; then
|
|
||||||
printf 'backup succeeded after mocked scp failure\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
assert_no_plain_dump "$scp_fail_dir"
|
|
||||||
test "$(find "$scp_fail_dir" -maxdepth 1 -type f | wc -l)" -eq 2
|
|
||||||
|
|
||||||
printf 'backup tests passed: syntax, CMS round-trip, pg_dump/OpenSSL/scp failures, no plaintext dump on disk\n'
|
|
||||||
|
|
@ -1,697 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// Приёмка веток исполнителей одной командой (TASK-GROK-3 п.1–2).
|
|
||||||
//
|
|
||||||
// node scripts/test/accept-branch.mjs [опции] <ветка> [<ветка>…]
|
|
||||||
//
|
|
||||||
// Для основы (--base) и каждой ветки:
|
|
||||||
// 1. git worktree в $ACCEPT_ROOT (по умолчанию ../krona-staff-acceptance), detached HEAD;
|
|
||||||
// node_modules — symlink на текущий репозиторий, если package-lock.json совпадает, иначе npm ci;
|
|
||||||
// 2. оверлей ТЕКУЩЕГО стенда: tests/e2e/** и scripts/test/** из этого checkout
|
|
||||||
// (tests/integration, src, миграции — версии ветки, не трогаются);
|
|
||||||
// 3. `npm run check` без переменных БД;
|
|
||||||
// 4. КАЖДЫЙ tests/integration/*.test.ts отдельно на свежей мигрированной БД PostgreSQL;
|
|
||||||
// красный — по строке `Test Files` (ловит ошибки beforeAll/afterAll, при которых
|
|
||||||
// `Tests N passed`), плюс ненулевой exit;
|
|
||||||
// 5. e2e (scripts/test/run-e2e.mjs, миграции roundtrip) и сравнение с основой по ID сценария;
|
|
||||||
// 6. попарно `git merge-tree --write-tree` — все ветки между собой и с основой;
|
|
||||||
// 7. «форматирование без поведения» для веток с `format` в имени и для --format-check <ветка>:
|
|
||||||
// версия файла из merge-base → prettier (конфиг основы) → сравнение с версией ветки.
|
|
||||||
// Итог — markdown в stdout (и в --out). Логи — в tests/e2e/evidence/accept/<время>/.
|
|
||||||
//
|
|
||||||
// БД: по умолчанию E2E_PG_ADMIN_URL (bootstrap-админ) один раз создаёт роли со случайными
|
|
||||||
// паролями, дальше только пересоздаёт БД `krona_staff_audit_acc_it`/`krona_acc_e2e` (provision-pg --roles keep).
|
|
||||||
// Интеграционная БД в этом режиме заведомо изолирована, поэтому для тестов с «предохранителем»
|
|
||||||
// (codex/fix-live-database: DATABASE_TEST_ISOLATED, DATABASE_TEST_GUARD_TOKEN, таблица
|
|
||||||
// krona_staff_test_guard, имя БД krona_staff_audit_*) он выставляется со случайным маркером.
|
|
||||||
// Файл, где все тесты пропущены, — отдельный вердикт SKIP (ничего не проверил).
|
|
||||||
// Если заданы E2E_MIGRATOR_URL и E2E_APP_URL — роли не создаются и пароли не меняются,
|
|
||||||
// все прогоны идут последовательно в БД из этих URL со сбросом через откат всех миграций.
|
|
||||||
//
|
|
||||||
// Опции: --base <ref> (origin/codex/bootstrap-platform-foundation), --format-check <ref>
|
|
||||||
// (повторяемая; ветка проверяется только на формат, без worktree), --skip-check,
|
|
||||||
// --skip-integration, --skip-e2e, --out <файл.md>.
|
|
||||||
// Ветка задаётся как `origin/<имя>`, `<имя>` (ищется origin/<имя>, затем локальный ref) или SHA.
|
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
|
||||||
import {
|
|
||||||
cpSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
readdirSync,
|
|
||||||
rmSync,
|
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import pg from 'pg';
|
|
||||||
import * as prettier from 'prettier';
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(import.meta.dirname, '../..');
|
|
||||||
const acceptRoot = path.resolve(
|
|
||||||
process.env.ACCEPT_ROOT ?? path.join(repoRoot, '..', 'krona-staff-acceptance'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------- аргументы ----------
|
|
||||||
const argv = process.argv.slice(2);
|
|
||||||
const opts = {
|
|
||||||
base: 'origin/codex/bootstrap-platform-foundation',
|
|
||||||
formatOnly: [],
|
|
||||||
skipCheck: false,
|
|
||||||
skipIntegration: false,
|
|
||||||
skipE2e: false,
|
|
||||||
out: null,
|
|
||||||
branches: [],
|
|
||||||
};
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const arg = argv[i];
|
|
||||||
if (arg === '--base') opts.base = argv[++i];
|
|
||||||
else if (arg === '--format-check') opts.formatOnly.push(argv[++i]);
|
|
||||||
else if (arg === '--skip-check') opts.skipCheck = true;
|
|
||||||
else if (arg === '--skip-integration') opts.skipIntegration = true;
|
|
||||||
else if (arg === '--skip-e2e') opts.skipE2e = true;
|
|
||||||
else if (arg === '--out') opts.out = argv[++i];
|
|
||||||
else if (arg === '-h' || arg === '--help') {
|
|
||||||
console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\nimport ')[0]);
|
|
||||||
process.exit(0);
|
|
||||||
} else if (arg.startsWith('--')) {
|
|
||||||
console.error(`неизвестная опция ${arg}`);
|
|
||||||
process.exit(2);
|
|
||||||
} else opts.branches.push(arg);
|
|
||||||
}
|
|
||||||
if (opts.branches.length === 0 && opts.formatOnly.length === 0) {
|
|
||||||
console.error('Usage: node scripts/test/accept-branch.mjs [--base ref] <branch> [<branch>…]');
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- утилиты ----------
|
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
||||||
const logDir = path.join(repoRoot, 'tests/e2e/evidence/accept', stamp);
|
|
||||||
mkdirSync(logDir, { recursive: true });
|
|
||||||
const progress = (msg) => process.stderr.write(`[accept] ${msg}\n`);
|
|
||||||
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
||||||
|
|
||||||
function git(args, { cwd = repoRoot, allowFail = false } = {}) {
|
|
||||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
||||||
if (r.status !== 0 && !allowFail) {
|
|
||||||
throw new Error(`git ${args.join(' ')} → exit ${r.status}: ${r.stderr}`);
|
|
||||||
}
|
|
||||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(command, args, { cwd, env, logFile, timeoutMs = 20 * 60_000 }) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const child = spawn(command, args, { cwd, env });
|
|
||||||
let out = '';
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
out += `\n[accept] TIMEOUT ${timeoutMs} ms — процесс убит\n`;
|
|
||||||
child.kill('SIGKILL');
|
|
||||||
}, timeoutMs);
|
|
||||||
for (const stream of [child.stdout, child.stderr]) {
|
|
||||||
stream.on('data', (chunk) => {
|
|
||||||
out += chunk.toString();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
child.on('close', (code) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
const clean = stripAnsi(out);
|
|
||||||
if (logFile) writeFileSync(logFile, `$ ${[command, ...args].join(' ')}\n${redact(clean)}`);
|
|
||||||
resolve({ code: code ?? 1, out: clean });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Пароли в URL не должны попасть в логи/отчёт. */
|
|
||||||
function redact(text) {
|
|
||||||
return text.replace(/(postgres(?:ql)?:\/\/[^:\s/]+:)[^@\s]+@/g, '$1***@');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Окружение без переменных БД и секретов стенда. */
|
|
||||||
function cleanEnv(extra = {}) {
|
|
||||||
const env = {};
|
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
|
||||||
if (/^(DATABASE_|E2E_|TEST_DATABASE|POSTGRES_|KRONA_)/.test(key)) continue;
|
|
||||||
env[key] = value;
|
|
||||||
}
|
|
||||||
return { ...env, NO_COLOR: '1', FORCE_COLOR: '0', ...extra };
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRef(name) {
|
|
||||||
const candidates = name.startsWith('origin/') ? [name] : [`origin/${name}`, name];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const r = git(['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], {
|
|
||||||
allowFail: true,
|
|
||||||
});
|
|
||||||
if (r.code === 0) return { name, ref: candidate, sha: r.out.trim() };
|
|
||||||
}
|
|
||||||
throw new Error(`ветка ${name} не найдена (ни origin/${name}, ни локально)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slugOf = (name) =>
|
|
||||||
name
|
|
||||||
.replace(/^origin\//, '')
|
|
||||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
||||||
.slice(0, 80);
|
|
||||||
const short = (sha) => sha.slice(0, 7);
|
|
||||||
|
|
||||||
function firstError(text) {
|
|
||||||
const lines = text.split('\n');
|
|
||||||
const patterns = [
|
|
||||||
/((?:[A-Za-z]+Error|error): .+)$/,
|
|
||||||
/(AssertionError.+)$/,
|
|
||||||
/(Error: .+)$/,
|
|
||||||
/(FAIL .+)$/,
|
|
||||||
];
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
for (const line of lines) {
|
|
||||||
const m = pattern.exec(line.trim());
|
|
||||||
if (m) return m[1].replace(/\|/g, '\\|').slice(0, 180);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- БД ----------
|
|
||||||
const presetMigrator = process.env.E2E_MIGRATOR_URL;
|
|
||||||
const presetApp = process.env.E2E_APP_URL;
|
|
||||||
const adminUrl = process.env.E2E_PG_ADMIN_URL;
|
|
||||||
const dbMode = presetMigrator && presetApp ? 'preset' : 'admin';
|
|
||||||
let roleUrls = null;
|
|
||||||
|
|
||||||
function withDatabase(url, database) {
|
|
||||||
const u = new URL(url);
|
|
||||||
u.pathname = `/${database}`;
|
|
||||||
return u.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareRoles() {
|
|
||||||
if (dbMode === 'preset') {
|
|
||||||
roleUrls = { migrator: presetMigrator, app: presetApp };
|
|
||||||
return 'роли из E2E_MIGRATOR_URL/E2E_APP_URL (не пересоздавались)';
|
|
||||||
}
|
|
||||||
if (!adminUrl) {
|
|
||||||
throw new Error('нужен E2E_PG_ADMIN_URL или пара E2E_MIGRATOR_URL + E2E_APP_URL');
|
|
||||||
}
|
|
||||||
const dir = mkdtempSync(path.join(tmpdir(), 'krona-accept-'));
|
|
||||||
const envFile = path.join(dir, 'db.env');
|
|
||||||
const r = await run(
|
|
||||||
process.execPath,
|
|
||||||
['scripts/test/provision-pg.mjs', '--out', envFile, '--db', 'krona_acc_roles'],
|
|
||||||
{ cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } },
|
|
||||||
);
|
|
||||||
if (r.code !== 0) throw new Error(`provision: ${r.out}`);
|
|
||||||
const env = Object.fromEntries(
|
|
||||||
readFileSync(envFile, 'utf8')
|
|
||||||
.split('\n')
|
|
||||||
.filter((l) => l.includes('='))
|
|
||||||
.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]),
|
|
||||||
);
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
roleUrls = { migrator: env.DATABASE_MIGRATION_URL, app: env.DATABASE_URL };
|
|
||||||
return 'роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Чистая БД: в admin-режиме пересоздаётся, в preset — одна и та же (сброс откатом миграций). */
|
|
||||||
async function freshDatabase(name) {
|
|
||||||
if (dbMode === 'preset') return { ...roleUrls, reset: 'roundtrip' };
|
|
||||||
const r = await run(
|
|
||||||
process.execPath,
|
|
||||||
['scripts/test/provision-pg.mjs', '--roles', 'keep', '--db', name],
|
|
||||||
{ cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } },
|
|
||||||
);
|
|
||||||
if (r.code !== 0) throw new Error(`provision --roles keep ${name}: ${r.out}`);
|
|
||||||
return {
|
|
||||||
migrator: withDatabase(roleUrls.migrator, name),
|
|
||||||
app: withDatabase(roleUrls.app, name),
|
|
||||||
admin: withDatabase(adminUrl, name),
|
|
||||||
reset: 'up',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- worktree ----------
|
|
||||||
const repoLockHash = createHash('sha256')
|
|
||||||
.update(readFileSync(path.join(repoRoot, 'package-lock.json')))
|
|
||||||
.digest('hex');
|
|
||||||
|
|
||||||
async function prepareWorktree(target) {
|
|
||||||
const dir = path.join(acceptRoot, `wt-${slugOf(target.name)}`);
|
|
||||||
mkdirSync(acceptRoot, { recursive: true });
|
|
||||||
const registered = git(['worktree', 'list', '--porcelain'])
|
|
||||||
.out.split('\n')
|
|
||||||
.some((l) => l === `worktree ${dir}`);
|
|
||||||
if (registered) git(['worktree', 'remove', '--force', dir]);
|
|
||||||
else if (existsSync(dir)) throw new Error(`${dir} существует, но не является worktree`);
|
|
||||||
git(['worktree', 'add', '--detach', '--force', dir, target.sha]);
|
|
||||||
const lock = git(['show', `${target.sha}:package-lock.json`]).out;
|
|
||||||
const lockSame = createHash('sha256').update(lock).digest('hex') === repoLockHash;
|
|
||||||
if (lockSame) {
|
|
||||||
symlinkSync(path.join(repoRoot, 'node_modules'), path.join(dir, 'node_modules'), 'dir');
|
|
||||||
} else {
|
|
||||||
progress(`${target.name}: package-lock.json отличается — npm ci`);
|
|
||||||
const r = await run('npm', ['ci', '--no-audit', '--no-fund'], {
|
|
||||||
cwd: dir,
|
|
||||||
env: cleanEnv(),
|
|
||||||
logFile: path.join(logDir, `${slugOf(target.name)}-npm-ci.txt`),
|
|
||||||
});
|
|
||||||
if (r.code !== 0) throw new Error(`${target.name}: npm ci exit ${r.code}`);
|
|
||||||
}
|
|
||||||
// Оверлей стенда: tests/e2e/** (без evidence) и scripts/test/**.
|
|
||||||
const overlayTouched = git([
|
|
||||||
'diff',
|
|
||||||
'--name-only',
|
|
||||||
`${opts.baseTarget.sha}...${target.sha}`,
|
|
||||||
'--',
|
|
||||||
'tests/e2e',
|
|
||||||
'scripts/test',
|
|
||||||
]).out.trim();
|
|
||||||
for (const rel of ['tests/e2e', 'scripts/test']) {
|
|
||||||
rmSync(path.join(dir, rel), { recursive: true, force: true });
|
|
||||||
cpSync(path.join(repoRoot, rel), path.join(dir, rel), {
|
|
||||||
recursive: true,
|
|
||||||
filter: (src) => !src.includes(`${path.sep}tests${path.sep}e2e${path.sep}evidence`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
dir,
|
|
||||||
deps: lockSame ? 'symlink' : 'npm ci',
|
|
||||||
overlayTouched: overlayTouched ? overlayTouched.split('\n') : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- шаги ----------
|
|
||||||
async function stepCheck(target, wt) {
|
|
||||||
progress(`${target.name}: npm run check`);
|
|
||||||
const r = await run('npm', ['run', 'check'], {
|
|
||||||
cwd: wt.dir,
|
|
||||||
env: cleanEnv(),
|
|
||||||
logFile: path.join(logDir, `${slugOf(target.name)}-check.txt`),
|
|
||||||
});
|
|
||||||
const tests = /Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '';
|
|
||||||
return { ok: r.code === 0, code: r.code, tests, error: r.code === 0 ? '' : firstError(r.out) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrate(wt, db, logFile) {
|
|
||||||
const r = await run(process.execPath, ['scripts/test/migrate-roundtrip.mjs', db.reset], {
|
|
||||||
cwd: wt.dir,
|
|
||||||
env: cleanEnv({ DATABASE_MIGRATION_URL: db.migrator }),
|
|
||||||
logFile,
|
|
||||||
});
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Предохранитель изолированной тестовой БД (соглашение теста codex/fix-live-database). */
|
|
||||||
async function createGuard(db) {
|
|
||||||
const marker = randomBytes(16).toString('hex');
|
|
||||||
const client = new pg.Client({ connectionString: db.migrator });
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
await client.query(
|
|
||||||
'CREATE TABLE IF NOT EXISTS krona_staff_test_guard (marker text PRIMARY KEY)',
|
|
||||||
);
|
|
||||||
await client.query('INSERT INTO krona_staff_test_guard (marker) VALUES ($1)', [marker]);
|
|
||||||
await client.query('GRANT SELECT ON krona_staff_test_guard TO krona_staff_app');
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
return marker;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stepIntegration(target, wt) {
|
|
||||||
const dir = path.join(wt.dir, 'tests/integration');
|
|
||||||
const files = existsSync(dir)
|
|
||||||
? readdirSync(dir)
|
|
||||||
.filter((f) => f.endsWith('.test.ts'))
|
|
||||||
.sort()
|
|
||||||
: [];
|
|
||||||
const results = [];
|
|
||||||
for (const file of files) {
|
|
||||||
progress(`${target.name}: integration ${file}`);
|
|
||||||
const slug = `${slugOf(target.name)}-it-${file.replace(/\.test\.ts$/, '')}`;
|
|
||||||
const db = await freshDatabase('krona_staff_audit_acc_it');
|
|
||||||
const m = await migrate(wt, db, path.join(logDir, `${slug}-migrate.txt`));
|
|
||||||
const guard = m.code === 0 && dbMode === 'admin' ? await createGuard(db) : null;
|
|
||||||
if (m.code !== 0) {
|
|
||||||
results.push({
|
|
||||||
file,
|
|
||||||
ok: false,
|
|
||||||
files: '',
|
|
||||||
tests: '',
|
|
||||||
error: `миграции: ${firstError(m.out)}`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const r = await run(
|
|
||||||
process.execPath,
|
|
||||||
[path.join(wt.dir, 'node_modules/vitest/vitest.mjs'), 'run', `tests/integration/${file}`],
|
|
||||||
{
|
|
||||||
cwd: wt.dir,
|
|
||||||
env: cleanEnv({
|
|
||||||
DATABASE_TEST_URL: db.app,
|
|
||||||
DATABASE_TEST_MIGRATION_URL: db.migrator,
|
|
||||||
DATABASE_MIGRATION_URL: db.migrator,
|
|
||||||
...(guard ? { DATABASE_TEST_ISOLATED: 'true', DATABASE_TEST_GUARD_TOKEN: guard } : {}),
|
|
||||||
}),
|
|
||||||
logFile: path.join(logDir, `${slug}.txt`),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const filesLine = /Test Files\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '(нет строки Test Files)';
|
|
||||||
const testsLine = /^\s*Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '';
|
|
||||||
const ok = r.code === 0 && /\bpassed\b/.test(filesLine) && !/failed/.test(filesLine);
|
|
||||||
const skipped = r.code === 0 && /skipped/.test(filesLine) && !/passed|failed/.test(filesLine);
|
|
||||||
results.push({
|
|
||||||
file,
|
|
||||||
ok,
|
|
||||||
skipped,
|
|
||||||
files: filesLine,
|
|
||||||
tests: testsLine,
|
|
||||||
error: ok || skipped ? '' : firstError(r.out),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadE2e(file) {
|
|
||||||
const map = new Map();
|
|
||||||
for (const f of JSON.parse(readFileSync(file, 'utf8')).testResults) {
|
|
||||||
for (const t of f.assertionResults) {
|
|
||||||
const id = /^\[([A-Z0-9/-]+)\]/.exec(t.title)?.[1] ?? 'UNTAGGED';
|
|
||||||
const e = map.get(id) ?? { pass: 0, fail: 0, msg: '' };
|
|
||||||
if (t.status === 'passed') e.pass += 1;
|
|
||||||
else if (t.status === 'failed') {
|
|
||||||
e.fail += 1;
|
|
||||||
e.msg ||= stripAnsi(t.failureMessages?.[0] ?? '')
|
|
||||||
.split('\n')[0]
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.replace(/\|/g, '\\|')
|
|
||||||
.slice(0, 160);
|
|
||||||
}
|
|
||||||
map.set(id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stepE2e(target, wt) {
|
|
||||||
progress(`${target.name}: e2e`);
|
|
||||||
const db = await freshDatabase('krona_acc_e2e');
|
|
||||||
const slug = slugOf(target.name);
|
|
||||||
const env = cleanEnv({
|
|
||||||
E2E_MIGRATOR_URL: db.migrator,
|
|
||||||
E2E_APP_URL: db.app,
|
|
||||||
E2E_MIGRATIONS: 'roundtrip',
|
|
||||||
...(db.admin ? { E2E_DB_ADMIN_URL: db.admin } : {}),
|
|
||||||
});
|
|
||||||
const r = await run(process.execPath, ['scripts/test/run-e2e.mjs'], {
|
|
||||||
cwd: wt.dir,
|
|
||||||
env,
|
|
||||||
logFile: path.join(logDir, `${slug}-e2e.txt`),
|
|
||||||
});
|
|
||||||
const jsonPath = path.join(wt.dir, 'tests/e2e/evidence/e2e-results.json');
|
|
||||||
if (!existsSync(jsonPath))
|
|
||||||
return { ok: false, code: r.code, map: null, error: firstError(r.out) };
|
|
||||||
cpSync(jsonPath, path.join(logDir, `${slug}-e2e-results.json`));
|
|
||||||
const map = loadE2e(jsonPath);
|
|
||||||
const red = [...map.values()].filter((e) => e.fail > 0).length;
|
|
||||||
const verdict = /\[run-e2e\] (OK|FAIL)[^\n]*/.exec(r.out)?.[0] ?? '';
|
|
||||||
return { ok: r.code === 0, code: r.code, map, red, green: map.size - red, verdict };
|
|
||||||
}
|
|
||||||
|
|
||||||
function diffE2e(baseMap, map) {
|
|
||||||
const color = (e) => (e ? (e.fail ? 'RED' : 'GREEN') : 'нет');
|
|
||||||
const turnedGreen = [];
|
|
||||||
const turnedRed = [];
|
|
||||||
const other = [];
|
|
||||||
for (const id of [...new Set([...baseMap.keys(), ...map.keys()])].sort()) {
|
|
||||||
const a = color(baseMap.get(id));
|
|
||||||
const b = color(map.get(id));
|
|
||||||
if (a === b) continue;
|
|
||||||
if (a === 'RED' && b === 'GREEN') turnedGreen.push(id);
|
|
||||||
else if (a === 'GREEN' && b === 'RED') turnedRed.push(`${id} (${map.get(id).msg})`);
|
|
||||||
else other.push(`${id}: ${a}→${b}`);
|
|
||||||
}
|
|
||||||
return { turnedGreen, turnedRed, other };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeTree(a, b) {
|
|
||||||
const r = git(['merge-tree', '--write-tree', '--name-only', '--no-messages', a.sha, b.sha], {
|
|
||||||
allowFail: true,
|
|
||||||
});
|
|
||||||
if (r.code === 0) return { status: 'чисто', files: [] };
|
|
||||||
if (r.code === 1) {
|
|
||||||
const files = r.out.split('\n').slice(1).filter(Boolean);
|
|
||||||
return { status: 'КОНФЛИКТ', files };
|
|
||||||
}
|
|
||||||
return { status: `ошибка git (exit ${r.code})`, files: [r.err.trim()] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- форматирование без поведения ----------
|
|
||||||
async function prettierConfigFor(file) {
|
|
||||||
const anchor = path.join(opts.baseWorktree ?? repoRoot, file);
|
|
||||||
const config = (await prettier.resolveConfig(anchor)) ?? {};
|
|
||||||
const info = await prettier.getFileInfo(anchor, {
|
|
||||||
ignorePath: path.join(opts.baseWorktree ?? repoRoot, '.prettierignore'),
|
|
||||||
});
|
|
||||||
return { config, info };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function normalize(content, file) {
|
|
||||||
const { config, info } = await prettierConfigFor(file);
|
|
||||||
if (info.ignored || !info.inferredParser) return content;
|
|
||||||
return prettier.format(content, { ...config, filepath: file });
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFile(sha, file) {
|
|
||||||
const r = git(['show', `${sha}:${file}`], { allowFail: true });
|
|
||||||
return r.code === 0 ? r.out : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function changedLines(before, after) {
|
|
||||||
const dir = mkdtempSync(path.join(tmpdir(), 'krona-fmt-'));
|
|
||||||
writeFileSync(path.join(dir, 'a'), before);
|
|
||||||
writeFileSync(path.join(dir, 'b'), after);
|
|
||||||
const r = git(['diff', '--no-index', '-U0', '--no-color', 'a', 'b'], {
|
|
||||||
cwd: dir,
|
|
||||||
allowFail: true,
|
|
||||||
});
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
return r.out
|
|
||||||
.split('\n')
|
|
||||||
.filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l))
|
|
||||||
.map((l) => `${l[0]}${l.slice(1).trim()}`)
|
|
||||||
.filter((l) => l.length > 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formatCheck(target, others) {
|
|
||||||
const mergeBase = git(['merge-base', opts.baseTarget.sha, target.sha]).out.trim();
|
|
||||||
const changes = git(['diff', '--name-status', '--no-renames', mergeBase, target.sha])
|
|
||||||
.out.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((l) => ({ status: l[0], file: l.split('\t')[1] }));
|
|
||||||
const rows = [];
|
|
||||||
for (const { status, file } of changes) {
|
|
||||||
const touchesConfig = /(^|\/)\.prettier(rc|ignore)|prettier\.config\./.test(file);
|
|
||||||
if (status === 'D') {
|
|
||||||
rows.push({ file, verdict: 'удалён', lines: 0, same: [] });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const after = showFile(target.sha, file);
|
|
||||||
const before = status === 'A' ? null : showFile(mergeBase, file);
|
|
||||||
if (before === null) {
|
|
||||||
rows.push({ file, verdict: 'новый файл', lines: 0, same: [] });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const formatted = await normalize(before, file);
|
|
||||||
if (formatted === after && !touchesConfig) continue; // чистое форматирование
|
|
||||||
const lines = changedLines(formatted, after);
|
|
||||||
const same = [];
|
|
||||||
for (const other of others) {
|
|
||||||
const otherContent = showFile(other.sha, file);
|
|
||||||
if (otherContent === null) continue;
|
|
||||||
const otherMergeBase = git(['merge-base', opts.baseTarget.sha, other.sha]).out.trim();
|
|
||||||
const otherBase = showFile(otherMergeBase, file) ?? '';
|
|
||||||
const otherLines = new Set(
|
|
||||||
changedLines(await normalize(otherBase, file), await normalize(otherContent, file)),
|
|
||||||
);
|
|
||||||
const matched = lines.filter((l) => otherLines.has(l)).length;
|
|
||||||
if (matched > 0) same.push(`${other.name}: ${matched}/${lines.length}`);
|
|
||||||
}
|
|
||||||
rows.push({
|
|
||||||
file,
|
|
||||||
verdict: touchesConfig ? 'меняет конфиг prettier' : 'не совпадает с prettier(база)',
|
|
||||||
lines: lines.length,
|
|
||||||
same,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { mergeBase, files: changes.length, rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- main ----------
|
|
||||||
opts.baseTarget = resolveRef(opts.base);
|
|
||||||
const targets = opts.branches.map(resolveRef);
|
|
||||||
const formatTargets = [
|
|
||||||
...targets.filter((t) => /format/i.test(t.name)),
|
|
||||||
...opts.formatOnly.map(resolveRef),
|
|
||||||
];
|
|
||||||
const harnessHead = git(['rev-parse', '--short', 'HEAD']).out.trim();
|
|
||||||
const harnessDirty = git(['status', '--porcelain', '--', 'tests/e2e', 'scripts/test']).out.trim()
|
|
||||||
? ' (+ незакоммиченные изменения стенда)'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const needRuns = !(opts.skipCheck && opts.skipIntegration && opts.skipE2e) && targets.length > 0;
|
|
||||||
let dbNote = 'не использовалась';
|
|
||||||
if (needRuns && !(opts.skipIntegration && opts.skipE2e)) dbNote = await prepareRoles();
|
|
||||||
|
|
||||||
const results = new Map();
|
|
||||||
const all = [opts.baseTarget, ...targets];
|
|
||||||
for (const target of all) {
|
|
||||||
const res = { target };
|
|
||||||
results.set(target, res);
|
|
||||||
if (!needRuns && target !== opts.baseTarget) continue;
|
|
||||||
try {
|
|
||||||
const wt = await prepareWorktree(target);
|
|
||||||
res.wt = wt;
|
|
||||||
if (target === opts.baseTarget) opts.baseWorktree = wt.dir;
|
|
||||||
if (!needRuns) continue;
|
|
||||||
if (!opts.skipCheck) res.check = await stepCheck(target, wt);
|
|
||||||
if (!opts.skipIntegration) res.integration = await stepIntegration(target, wt);
|
|
||||||
if (!opts.skipE2e) res.e2e = await stepE2e(target, wt);
|
|
||||||
} catch (error) {
|
|
||||||
res.fatal = redact(String(error?.message ?? error)).slice(0, 300);
|
|
||||||
progress(`${target.name}: ${res.fatal}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatResults = [];
|
|
||||||
for (const target of formatTargets) {
|
|
||||||
progress(`${target.name}: проверка форматирования`);
|
|
||||||
const others = targets.filter((t) => t !== target && !/format/i.test(t.name));
|
|
||||||
formatResults.push({ target, ...(await formatCheck(target, others)) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- markdown ----------
|
|
||||||
const md = [];
|
|
||||||
const baseRes = results.get(opts.baseTarget);
|
|
||||||
md.push(`## accept-branch — ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC`);
|
|
||||||
md.push('');
|
|
||||||
md.push(
|
|
||||||
`База: \`${opts.baseTarget.ref}\` = ${short(opts.baseTarget.sha)}; стенд (оверлей tests/e2e, scripts/test): ${harnessHead}${harnessDirty}; БД: ${dbNote}. Логи: \`${path.relative(repoRoot, logDir)}/\`.`,
|
|
||||||
);
|
|
||||||
md.push('');
|
|
||||||
if (needRuns) {
|
|
||||||
md.push('### Сводка');
|
|
||||||
md.push('');
|
|
||||||
md.push(
|
|
||||||
'| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |',
|
|
||||||
);
|
|
||||||
md.push('|---|---|---|---|---|---|---|');
|
|
||||||
for (const res of results.values()) {
|
|
||||||
const t = res.target;
|
|
||||||
const isBase = t === opts.baseTarget;
|
|
||||||
const name = isBase ? `${t.ref} (база)` : t.ref;
|
|
||||||
if (res.fatal) {
|
|
||||||
md.push(`| ${name} | ${short(t.sha)} | ОШИБКА: ${res.fatal} | | | | |`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const check = res.check
|
|
||||||
? res.check.ok
|
|
||||||
? `OK (${res.check.tests})`
|
|
||||||
: `FAIL exit=${res.check.code}: ${res.check.error}`
|
|
||||||
: 'не запускался';
|
|
||||||
const reds = res.integration?.filter((r) => !r.ok && !r.skipped).map((r) => r.file) ?? [];
|
|
||||||
const skips = res.integration?.filter((r) => r.skipped).map((r) => r.file) ?? [];
|
|
||||||
const greens = (res.integration?.length ?? 0) - reds.length - skips.length;
|
|
||||||
const integration = res.integration
|
|
||||||
? `${greens}/${res.integration.length} зелёных${reds.length ? `; FAIL: ${reds.join(', ')}` : ''}${skips.length ? `; SKIP (файл пропущен целиком): ${skips.join(', ')}` : ''}`
|
|
||||||
: 'не запускались';
|
|
||||||
let e2e = 'не запускался';
|
|
||||||
let green = '';
|
|
||||||
let red = '';
|
|
||||||
if (res.e2e?.map) {
|
|
||||||
e2e = `${res.e2e.red}/${res.e2e.green}`;
|
|
||||||
if (!isBase && baseRes.e2e?.map) {
|
|
||||||
const d = diffE2e(baseRes.e2e.map, res.e2e.map);
|
|
||||||
green = d.turnedGreen.join(', ') || '—';
|
|
||||||
red = [...d.turnedRed, ...d.other].join('; ') || '—';
|
|
||||||
}
|
|
||||||
} else if (res.e2e) e2e = `стенд не поднялся: ${res.e2e.error}`;
|
|
||||||
if (isBase) {
|
|
||||||
green = 'n/a';
|
|
||||||
red = 'n/a';
|
|
||||||
}
|
|
||||||
md.push(
|
|
||||||
`| ${name} | ${short(t.sha)} | ${check} | ${integration} | ${e2e} | ${green} | ${red} |`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
md.push('');
|
|
||||||
if (
|
|
||||||
results.values().next().value.integration ||
|
|
||||||
targets.some((t) => results.get(t).integration)
|
|
||||||
) {
|
|
||||||
md.push('### Интеграционные тесты (каждый файл отдельно, свежая БД)');
|
|
||||||
md.push('');
|
|
||||||
md.push('| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |');
|
|
||||||
md.push('|---|---|---|---|---|---|');
|
|
||||||
for (const res of results.values()) {
|
|
||||||
for (const r of res.integration ?? []) {
|
|
||||||
md.push(
|
|
||||||
`| ${res.target.ref} | ${r.file} | ${r.ok ? 'OK' : r.skipped ? 'SKIP' : 'FAIL'} | ${r.files} | ${r.tests} | ${r.error} |`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
md.push('');
|
|
||||||
}
|
|
||||||
const overlayNotes = [...results.values()].filter((r) => r.wt?.overlayTouched.length);
|
|
||||||
if (overlayNotes.length) {
|
|
||||||
md.push('Внимание: ветка меняла файлы стенда, при приёмке они заменены оверлеем:');
|
|
||||||
for (const r of overlayNotes) md.push(`- ${r.target.ref}: ${r.wt.overlayTouched.join(', ')}`);
|
|
||||||
md.push('');
|
|
||||||
}
|
|
||||||
const npmCi = [...results.values()].filter((r) => r.wt?.deps === 'npm ci');
|
|
||||||
if (npmCi.length)
|
|
||||||
md.push(`npm ci (lock отличается): ${npmCi.map((r) => r.target.ref).join(', ')}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targets.length > 0) {
|
|
||||||
md.push('### Попарные слияния (`git merge-tree --write-tree`)');
|
|
||||||
md.push('');
|
|
||||||
md.push('| Ветка A | Ветка B | Результат | Конфликтные файлы |');
|
|
||||||
md.push('|---|---|---|---|');
|
|
||||||
const pairs = [];
|
|
||||||
for (const t of targets) pairs.push([opts.baseTarget, t]);
|
|
||||||
for (let i = 0; i < targets.length; i += 1) {
|
|
||||||
for (let j = i + 1; j < targets.length; j += 1) pairs.push([targets[i], targets[j]]);
|
|
||||||
}
|
|
||||||
for (const [a, b] of pairs) {
|
|
||||||
const r = mergeTree(a, b);
|
|
||||||
md.push(`| ${a.ref} | ${b.ref} | ${r.status} | ${r.files.join(', ')} |`);
|
|
||||||
}
|
|
||||||
md.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formatResults.length) {
|
|
||||||
md.push('### Форматирование без поведения');
|
|
||||||
md.push('');
|
|
||||||
for (const f of formatResults) {
|
|
||||||
const verdict = f.rows.length === 0 ? 'ТОЛЬКО ФОРМАТИРОВАНИЕ' : 'ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER';
|
|
||||||
md.push(
|
|
||||||
`**${f.target.ref}** (${short(f.target.sha)}), merge-base ${short(f.mergeBase)}, изменено файлов: ${f.files} — ${verdict}`,
|
|
||||||
);
|
|
||||||
md.push('');
|
|
||||||
if (f.rows.length) {
|
|
||||||
md.push('| Файл | Почему | Строк сверх prettier | Эти же строки в ветке |');
|
|
||||||
md.push('|---|---|---|---|');
|
|
||||||
for (const r of f.rows) {
|
|
||||||
md.push(`| ${r.file} | ${r.verdict} | ${r.lines} | ${r.same.join('; ') || '—'} |`);
|
|
||||||
}
|
|
||||||
md.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = redact(md.join('\n'));
|
|
||||||
writeFileSync(path.join(logDir, 'summary.md'), text);
|
|
||||||
if (opts.out) writeFileSync(opts.out, text);
|
|
||||||
console.log(text);
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Локальный эквивалент задач verify/format/e2e из .forgejo/workflows/ci.yml
|
|
||||||
# (без Docker: PostgreSQL 17 уже запущен, E2E_PG_ADMIN_URL указывает на bootstrap-админа).
|
|
||||||
# docker-build локально не воспроизводится: Docker в этой среде нет.
|
|
||||||
set -uo pipefail
|
|
||||||
cd "$(dirname "$0")/../.."
|
|
||||||
: "${E2E_PG_ADMIN_URL:?E2E_PG_ADMIN_URL (bootstrap-админ PostgreSQL 17) обязателен}"
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
|
||||||
status=0
|
|
||||||
step() {
|
|
||||||
local name="$1"
|
|
||||||
shift
|
|
||||||
echo "::: [$name] $*"
|
|
||||||
if "$@"; then echo "::: [$name] OK"; else local code=$?; echo "::: [$name] FAIL exit=$code"; status=1; fi
|
|
||||||
}
|
|
||||||
with_db_env() { (set -a; . "$tmp/db.env"; set +a; unset DATABASE_URL POSTGRES_APP_PASSWORD; "$@"); }
|
|
||||||
|
|
||||||
echo "=== job verify"
|
|
||||||
step npm-ci npm ci --no-audit --no-fund
|
|
||||||
step provision node scripts/test/provision-pg.mjs --out "$tmp/db.env" --db krona_staff
|
|
||||||
step migrate-roundtrip with_db_env node scripts/test/migrate-roundtrip.mjs roundtrip
|
|
||||||
step check with_db_env npm run check
|
|
||||||
step audit npm audit --audit-level=high
|
|
||||||
echo "=== job format"
|
|
||||||
step format-check npm run format:check
|
|
||||||
echo "=== job e2e"
|
|
||||||
step e2e env E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs
|
|
||||||
echo "=== итог: status=$status"
|
|
||||||
exit $status
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// up -> откат ВСЕХ миграций по одной -> up -> повторный up (идемпотентность).
|
|
||||||
// Запускается ролью мигратора (DATABASE_MIGRATION_URL), без SUPERUSER.
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
|
|
||||||
function run(direction) {
|
|
||||||
const result = spawnSync(process.execPath, ['scripts/migrate.mjs', direction], {
|
|
||||||
env: { ...process.env, ALLOW_MIGRATION_DOWN: direction === 'down' ? 'true' : 'false' },
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
process.stdout.write(result.stdout);
|
|
||||||
process.stderr.write(result.stderr);
|
|
||||||
if (result.status !== 0) {
|
|
||||||
console.error(`migrate ${direction} failed with exit ${result.status}`);
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
return result.stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = process.argv[2] ?? 'roundtrip';
|
|
||||||
console.log('== migrate up (1)');
|
|
||||||
const first = run('up');
|
|
||||||
if (mode === 'up') process.exit(0);
|
|
||||||
const applied = (first.match(/^applied /gm) ?? []).length;
|
|
||||||
console.log(`== rollback all (applied now: ${applied})`);
|
|
||||||
let rolledBack = 0;
|
|
||||||
for (let i = 0; i < 1000; i += 1) {
|
|
||||||
const out = run('down');
|
|
||||||
if (/no migration to roll back/.test(out)) break;
|
|
||||||
rolledBack += 1;
|
|
||||||
}
|
|
||||||
console.log(`== rolled back ${rolledBack} migrations`);
|
|
||||||
console.log('== migrate up (2)');
|
|
||||||
const second = run('up');
|
|
||||||
const reapplied = (second.match(/^applied /gm) ?? []).length;
|
|
||||||
console.log('== migrate up (3, must be no-op)');
|
|
||||||
const third = run('up');
|
|
||||||
if ((third.match(/^applied /gm) ?? []).length !== 0) {
|
|
||||||
console.error('third up applied migrations again: not idempotent');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (rolledBack === 0 || reapplied !== rolledBack) {
|
|
||||||
console.error(`rollback/reapply mismatch: rolledBack=${rolledBack} reapplied=${reapplied}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(`== roundtrip OK: rolledBack=${rolledBack} reapplied=${reapplied}`);
|
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// N13: гонка первого расчёта зарплаты. N параллельных POST /payroll/calculate для одной и той же
|
|
||||||
// пары (точка, месяц), когда расчёта ещё нет. Каждая попытка — новая точка (значит «первый» расчёт).
|
|
||||||
//
|
|
||||||
// Режимы:
|
|
||||||
// --mode as-is код и схема как есть (N2 не обходится);
|
|
||||||
// --mode n2-bypass ДИАГНОСТИКА: в одноразовой БД мигратор ставит DEFAULT now() для
|
|
||||||
// staff_outbox.occurred_at, чтобы обойти N2 и увидеть саму гонку.
|
|
||||||
// Продукт/миграции не меняются, БД удаляется при следующем provision.
|
|
||||||
// Опции: --sizes 10,25,50 --attempts 5 --with-sessions (подтверждённые смены, нужен n2-bypass)
|
|
||||||
// Требует E2E_PG_ADMIN_URL (bootstrap-админ только для создания ролей/БД).
|
|
||||||
// Правильное поведение: все ответы 200, один и тот же run_id, ровно один расчёт в БД,
|
|
||||||
// каждый возвращённый run_id существует в БД.
|
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
||||||
import { createServer } from 'node:net';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import pg from 'pg';
|
|
||||||
import { baseEnv, nodeWithTsx } from './node-commands.mjs';
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(import.meta.dirname, '../..');
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const opt = (name, fallback) => {
|
|
||||||
const index = args.indexOf(`--${name}`);
|
|
||||||
return index >= 0 ? args[index + 1] : fallback;
|
|
||||||
};
|
|
||||||
const mode = opt('mode', 'as-is');
|
|
||||||
const sizes = opt('sizes', '10,25,50').split(',').map(Number);
|
|
||||||
const attempts = Number(opt('attempts', '5'));
|
|
||||||
const withSessions = args.includes('--with-sessions');
|
|
||||||
const database = opt('db', `krona_staff_n13_${mode.replace(/-/g, '_')}`);
|
|
||||||
|
|
||||||
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
||||||
const ulid = () =>
|
|
||||||
Array.from(randomBytes(26), (byte) => CROCKFORD[byte % 32])
|
|
||||||
.join('')
|
|
||||||
.replace(/^./, '0');
|
|
||||||
|
|
||||||
function parseEnvFile(file) {
|
|
||||||
const result = {};
|
|
||||||
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
||||||
const index = line.indexOf('=');
|
|
||||||
if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runNode(scriptArgs, env) {
|
|
||||||
const result = spawnSync(process.execPath, scriptArgs, { cwd: repoRoot, env, encoding: 'utf8' });
|
|
||||||
if (result.status !== 0) {
|
|
||||||
process.stdout.write(`${result.stdout}${result.stderr}`);
|
|
||||||
throw new Error(`${scriptArgs.join(' ')} failed`);
|
|
||||||
}
|
|
||||||
return result.stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function freePort() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = createServer();
|
|
||||||
server.listen(0, '127.0.0.1', () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUrl = process.env.E2E_PG_ADMIN_URL;
|
|
||||||
if (!adminUrl) throw new Error('E2E_PG_ADMIN_URL не задан');
|
|
||||||
const secretsDir = mkdtempSync(path.join(tmpdir(), 'krona-n13-'));
|
|
||||||
runNode(
|
|
||||||
['scripts/test/provision-pg.mjs', '--out', path.join(secretsDir, 'db.env'), '--db', database],
|
|
||||||
{
|
|
||||||
...process.env,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const dbEnv = parseEnvFile(path.join(secretsDir, 'db.env'));
|
|
||||||
rmSync(secretsDir, { recursive: true, force: true });
|
|
||||||
runNode(['scripts/test/migrate-roundtrip.mjs', 'up'], {
|
|
||||||
...baseEnv(),
|
|
||||||
DATABASE_MIGRATION_URL: dbEnv.DATABASE_MIGRATION_URL,
|
|
||||||
});
|
|
||||||
const migrator = new pg.Client({ connectionString: dbEnv.DATABASE_MIGRATION_URL });
|
|
||||||
await migrator.connect();
|
|
||||||
if (mode === 'n2-bypass') {
|
|
||||||
await migrator.query('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()');
|
|
||||||
console.log(
|
|
||||||
'[n13] ДИАГНОСТИКА: staff_outbox.occurred_at DEFAULT now() (обход N2 только в этой БД)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenantId = ulid();
|
|
||||||
const managerToken = randomBytes(32).toString('hex');
|
|
||||||
const terminalToken = randomBytes(32).toString('hex');
|
|
||||||
const vapid = {};
|
|
||||||
for (const line of runNode(['scripts/generate-vapid-keys.mjs'], process.env).split('\n')) {
|
|
||||||
const index = line.indexOf('=');
|
|
||||||
if (index > 0) vapid[line.slice(0, index)] = line.slice(index + 1);
|
|
||||||
}
|
|
||||||
const port = await freePort();
|
|
||||||
const log = createWriteStream(path.join(repoRoot, `tests/e2e/evidence/server-n13-${mode}.txt`));
|
|
||||||
const server = nodeWithTsx(['src/server.ts']);
|
|
||||||
const child = spawn(server.command, server.args, {
|
|
||||||
cwd: repoRoot,
|
|
||||||
env: {
|
|
||||||
...baseEnv(),
|
|
||||||
NODE_ENV: 'test',
|
|
||||||
HOST: '127.0.0.1',
|
|
||||||
PORT: String(port),
|
|
||||||
LOG_LEVEL: 'warn',
|
|
||||||
STAFF_RUNTIME_MODE: 'standalone',
|
|
||||||
DATABASE_URL: dbEnv.DATABASE_URL,
|
|
||||||
STANDALONE_TENANT_ID: tenantId,
|
|
||||||
STANDALONE_ACTOR_ID: ulid(),
|
|
||||||
STANDALONE_ACCESS: 'employee',
|
|
||||||
STANDALONE_MANAGER_TOKEN: managerToken,
|
|
||||||
STANDALONE_TERMINAL_TOKEN: terminalToken,
|
|
||||||
STANDALONE_TERMINAL_ACTOR_ID: ulid(),
|
|
||||||
...vapid,
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
child.stdout.pipe(log);
|
|
||||||
child.stderr.pipe(log);
|
|
||||||
const base = `http://127.0.0.1:${port}/api/v1/staff`;
|
|
||||||
|
|
||||||
async function api(method, route, token, body) {
|
|
||||||
const response = await fetch(`${base}${route}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
...(body ? { 'content-type': 'application/json' } : {}),
|
|
||||||
},
|
|
||||||
...(body ? { body: JSON.stringify(body) } : {}),
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
let json = null;
|
|
||||||
try {
|
|
||||||
json = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
// не JSON
|
|
||||||
}
|
|
||||||
return { status: response.status, json, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < 240; i += 1) {
|
|
||||||
try {
|
|
||||||
if ((await fetch(`${base}/ready`)).status === 200) break;
|
|
||||||
} catch {
|
|
||||||
// ещё не слушает
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
}
|
|
||||||
await migrator.query("SELECT set_config('app.tenant_id', $1, false)", [tenantId]);
|
|
||||||
|
|
||||||
const month = (() => {
|
|
||||||
const date = new Date();
|
|
||||||
date.setUTCDate(1);
|
|
||||||
date.setUTCMonth(date.getUTCMonth() - 1);
|
|
||||||
return date.toISOString().slice(0, 7);
|
|
||||||
})();
|
|
||||||
let hourlyRule = null;
|
|
||||||
if (withSessions) {
|
|
||||||
const rules = await api('GET', '/pay-rules', managerToken);
|
|
||||||
hourlyRule = (
|
|
||||||
rules.json.pay_rules.find((rule) => rule.code === 'hourly') ?? rules.json.pay_rules[0]
|
|
||||||
).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareUnit() {
|
|
||||||
const code = `n13-${ulid().slice(-10).toLowerCase()}`;
|
|
||||||
const suffix = ulid().slice(-8);
|
|
||||||
const imported = await api('POST', '/imports/owner-workbook', managerToken, {
|
|
||||||
source_name: `n13-import-${suffix}`,
|
|
||||||
units: [{ code, name: `Синтетическая точка ${suffix}` }],
|
|
||||||
...(withSessions
|
|
||||||
? {
|
|
||||||
employees: [
|
|
||||||
{
|
|
||||||
external_ref: `n13:${suffix}`,
|
|
||||||
personnel_number: `N13-${suffix}`,
|
|
||||||
display_name: `Синтетический ${suffix}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
if (imported.status >= 300) throw new Error(`import ${imported.status} ${imported.text}`);
|
|
||||||
const unitId = (await migrator.query('SELECT id FROM staff_org_units WHERE code = $1', [code]))
|
|
||||||
.rows[0].id;
|
|
||||||
if (withSessions) {
|
|
||||||
const employeeId = (
|
|
||||||
await migrator.query('SELECT id FROM staff_employees WHERE external_ref = $1', [
|
|
||||||
`n13:${suffix}`,
|
|
||||||
])
|
|
||||||
).rows[0].id;
|
|
||||||
const assign = await api('PUT', `/employees/${employeeId}/pay-assignment`, managerToken, {
|
|
||||||
pay_rule_id: hourlyRule,
|
|
||||||
org_unit_id: unitId,
|
|
||||||
rate_minor: 12345,
|
|
||||||
effective_from: '2026-01-01',
|
|
||||||
});
|
|
||||||
if (assign.status !== 200) throw new Error(`assign ${assign.status} ${assign.text}`);
|
|
||||||
const start = new Date(`${month}-10T09:00:00Z`);
|
|
||||||
for (const [eventType, at] of [
|
|
||||||
['check_in', start],
|
|
||||||
['check_out', new Date(start.getTime() + 8 * 3_600_000)],
|
|
||||||
]) {
|
|
||||||
const event = await api('POST', '/attendance/events', terminalToken, {
|
|
||||||
employee_id: employeeId,
|
|
||||||
event_type: eventType,
|
|
||||||
occurred_at: at.toISOString(),
|
|
||||||
source_event_id: `n13-${ulid()}`,
|
|
||||||
org_unit_id: unitId,
|
|
||||||
});
|
|
||||||
if (event.status !== 201) throw new Error(`clock ${event.status} ${event.text}`);
|
|
||||||
}
|
|
||||||
const day = start.toISOString().slice(0, 10);
|
|
||||||
const reconcile = await api('POST', '/work-sessions/reconcile', managerToken, {
|
|
||||||
date_from: day,
|
|
||||||
date_to: day,
|
|
||||||
});
|
|
||||||
if (reconcile.status !== 200) throw new Error(`reconcile ${reconcile.status}`);
|
|
||||||
const sessionId = (
|
|
||||||
await migrator.query('SELECT id FROM staff_work_sessions WHERE employee_id = $1', [
|
|
||||||
employeeId,
|
|
||||||
])
|
|
||||||
).rows[0].id;
|
|
||||||
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, managerToken, {
|
|
||||||
status: 'confirmed',
|
|
||||||
});
|
|
||||||
if (confirm.status !== 200)
|
|
||||||
throw new Error(`confirm ${confirm.status} ${confirm.text.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
return unitId;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalBad = 0;
|
|
||||||
const summary = [];
|
|
||||||
for (const size of sizes) {
|
|
||||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
||||||
const unitId = await prepareUnit();
|
|
||||||
const responses = await Promise.all(
|
|
||||||
Array.from({ length: size }, () =>
|
|
||||||
api('POST', '/payroll/calculate', managerToken, { month, org_unit_id: unitId }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const statuses = {};
|
|
||||||
const errors = {};
|
|
||||||
const runIds = new Set();
|
|
||||||
for (const response of responses) {
|
|
||||||
statuses[response.status] = (statuses[response.status] ?? 0) + 1;
|
|
||||||
if (response.status === 200) {
|
|
||||||
const id = response.json?.run_id ?? response.json?.id ?? response.json?.run?.id;
|
|
||||||
if (id) runIds.add(id);
|
|
||||||
} else {
|
|
||||||
const key = (
|
|
||||||
response.json?.error?.message ??
|
|
||||||
response.json?.message ??
|
|
||||||
response.text
|
|
||||||
).slice(0, 90);
|
|
||||||
errors[key] = (errors[key] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dbRuns = (
|
|
||||||
await migrator.query('SELECT id FROM staff_payroll_runs WHERE org_unit_id = $1', [unitId])
|
|
||||||
).rows.map((row) => row.id);
|
|
||||||
const dbLines = Number(
|
|
||||||
(
|
|
||||||
await migrator.query(
|
|
||||||
'SELECT count(*) AS n FROM staff_payroll_lines WHERE org_unit_id = $1',
|
|
||||||
[unitId],
|
|
||||||
)
|
|
||||||
).rows[0].n,
|
|
||||||
);
|
|
||||||
const phantom = [...runIds].filter((id) => !dbRuns.includes(id));
|
|
||||||
const ok =
|
|
||||||
(statuses[200] ?? 0) === size &&
|
|
||||||
runIds.size === 1 &&
|
|
||||||
dbRuns.length === 1 &&
|
|
||||||
phantom.length === 0;
|
|
||||||
if (!ok) totalBad += 1;
|
|
||||||
const line =
|
|
||||||
`[n13] mode=${mode} sessions=${withSessions} N=${size} attempt=${attempt}: ` +
|
|
||||||
`statuses=${JSON.stringify(statuses)} distinct_run_ids=${runIds.size} db_runs=${dbRuns.length} db_lines=${dbLines} ` +
|
|
||||||
`phantom_run_ids=${phantom.length} errors=${JSON.stringify(errors)} -> ${ok ? 'OK' : 'BAD'}`;
|
|
||||||
console.log(line);
|
|
||||||
summary.push(ok);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`[n13] ИТОГ mode=${mode} sessions=${withSessions}: попыток ${summary.length}, некорректных ${totalBad}`,
|
|
||||||
);
|
|
||||||
process.exitCode = totalBad > 0 ? 1 : 0;
|
|
||||||
} finally {
|
|
||||||
await migrator.end();
|
|
||||||
child.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export type Tool = 'tsx' | 'vitest' | 'prettier';
|
|
||||||
export declare function toolEntry(tool: Tool): string;
|
|
||||||
export declare function nodeTool(tool: Tool, args?: string[]): { command: string; args: string[] };
|
|
||||||
export declare function nodeWithTsx(args?: string[]): { command: string; args: string[] };
|
|
||||||
export declare function baseEnv(
|
|
||||||
platform?: NodeJS.Platform,
|
|
||||||
env?: NodeJS.ProcessEnv,
|
|
||||||
): Record<string, string>;
|
|
||||||
export declare const repoRoot: string;
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
// Запуск инструментов из node_modules без .cmd-обёрток и без npx.
|
|
||||||
// Под Windows `node_modules/.bin/tsx` и `npx` — это .cmd-файлы: spawn() без shell
|
|
||||||
// падает с ENOENT. Поэтому всегда запускаем текущий node (process.execPath) с
|
|
||||||
// JS-точкой входа пакета, найденной через require.resolve.
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(import.meta.dirname, '../..');
|
|
||||||
const require = createRequire(path.join(repoRoot, 'package.json'));
|
|
||||||
|
|
||||||
function packageBin(name, binName = name) {
|
|
||||||
const manifestPath = require.resolve(`${name}/package.json`);
|
|
||||||
const manifest = require(manifestPath);
|
|
||||||
const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[binName];
|
|
||||||
if (!bin) throw new Error(`${name}: в package.json нет bin "${binName}"`);
|
|
||||||
return path.join(path.dirname(manifestPath), bin);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Абсолютный путь к JS-точке входа CLI (не .cmd, не .bin). */
|
|
||||||
export function toolEntry(tool) {
|
|
||||||
switch (tool) {
|
|
||||||
case 'tsx':
|
|
||||||
return require.resolve('tsx/cli');
|
|
||||||
case 'vitest':
|
|
||||||
return packageBin('vitest');
|
|
||||||
case 'prettier':
|
|
||||||
return packageBin('prettier');
|
|
||||||
default:
|
|
||||||
throw new Error(`неизвестный инструмент: ${tool}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Команда для spawn(command, args): всегда process.execPath + JS-файл CLI. */
|
|
||||||
export function nodeTool(tool, args = []) {
|
|
||||||
return { command: process.execPath, args: [toolEntry(tool), ...args] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Запуск TypeScript-файла (например, src/server.ts) в ОДНОМ процессе node с
|
|
||||||
* загрузчиком tsx (`node --import tsx`). CLI tsx запускает дочерний node, и под
|
|
||||||
* Windows kill() родителя (TerminateProcess) оставил бы сервер-сироту на порту.
|
|
||||||
*/
|
|
||||||
export function nodeWithTsx(args = []) {
|
|
||||||
const loader = pathToFileURL(require.resolve('tsx')).href;
|
|
||||||
return { command: process.execPath, args: ['--import', loader, ...args] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Минимальное окружение дочернего процесса: PATH и (под Windows) системные
|
|
||||||
* переменные, без которых node не может работать с сетью/криптографией
|
|
||||||
* (SystemRoot, WINDIR, TEMP и т. п.). Секреты родителя не наследуются.
|
|
||||||
*/
|
|
||||||
export function baseEnv(platform = process.platform, env = process.env) {
|
|
||||||
const keys = ['PATH'];
|
|
||||||
if (platform === 'win32') {
|
|
||||||
keys.push(
|
|
||||||
'SystemRoot',
|
|
||||||
'WINDIR',
|
|
||||||
'TEMP',
|
|
||||||
'TMP',
|
|
||||||
'USERPROFILE',
|
|
||||||
'LOCALAPPDATA',
|
|
||||||
'APPDATA',
|
|
||||||
'ComSpec',
|
|
||||||
'PATHEXT',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const result = {};
|
|
||||||
for (const key of keys) {
|
|
||||||
if (env[key] !== undefined) result[key] = env[key];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { repoRoot };
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// Тестовый стенд PostgreSQL, повторяющий роли pilot, но без суперпользователя-мигратора:
|
|
||||||
// * bootstrap-админ (E2E_PG_ADMIN_URL) используется ТОЛЬКО здесь, как DBA/entrypoint образа;
|
|
||||||
// * krona_migrator — NOSUPERUSER NOCREATEDB NOCREATEROLE, владелец БД и схемы public;
|
|
||||||
// * krona_staff_app — NOSUPERUSER NOBYPASSRLS NOINHERIT, права выдают миграции 0003+.
|
|
||||||
// Пароли генерируются crypto.randomBytes и пишутся только в env-файл с правами 0600.
|
|
||||||
// Печатаются имена ролей/БД, но не пароли.
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
import pg from 'pg';
|
|
||||||
|
|
||||||
const args = new Map();
|
|
||||||
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i], process.argv[i + 1]);
|
|
||||||
const adminUrl = process.env.E2E_PG_ADMIN_URL;
|
|
||||||
const envFile = args.get('--out');
|
|
||||||
const database = args.get('--db') ?? 'krona_staff_e2e';
|
|
||||||
// --roles keep: роли уже созданы (например, предыдущим вызовом accept-branch) — пароли НЕ меняются,
|
|
||||||
// только пересоздаётся БД. Нужно, чтобы параллельные/последовательные стенды не ломали друг другу пароли.
|
|
||||||
const keepRoles = args.get('--roles') === 'keep';
|
|
||||||
if (!adminUrl || (!envFile && !keepRoles)) {
|
|
||||||
console.error(
|
|
||||||
'Usage: E2E_PG_ADMIN_URL=... node scripts/test/provision-pg.mjs --out <env-path> [--db name]\n' +
|
|
||||||
' E2E_PG_ADMIN_URL=... node scripts/test/provision-pg.mjs --roles keep --db name',
|
|
||||||
);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(database)) throw new Error('Invalid database name');
|
|
||||||
|
|
||||||
export async function provision() {
|
|
||||||
const migratorPassword = randomBytes(24).toString('hex');
|
|
||||||
const appPassword = randomBytes(24).toString('hex');
|
|
||||||
const admin = new pg.Client({
|
|
||||||
connectionString: adminUrl,
|
|
||||||
application_name: 'krona-e2e-provision',
|
|
||||||
});
|
|
||||||
await admin.connect();
|
|
||||||
try {
|
|
||||||
if (keepRoles) {
|
|
||||||
const existing = await admin.query(
|
|
||||||
`SELECT count(*)::int AS n FROM pg_roles WHERE rolname IN ('krona_migrator','krona_staff_app')`,
|
|
||||||
);
|
|
||||||
if (existing.rows[0].n !== 2)
|
|
||||||
throw new Error('--roles keep: роли krona_migrator/krona_staff_app не созданы');
|
|
||||||
} else {
|
|
||||||
await admin.query(`
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_migrator') THEN
|
|
||||||
CREATE ROLE krona_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN
|
|
||||||
CREATE ROLE krona_staff_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
|
||||||
END IF;
|
|
||||||
END $$`);
|
|
||||||
const alterMigrator = await admin.query(
|
|
||||||
"SELECT format('ALTER ROLE krona_migrator WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS', $1::text) AS sql",
|
|
||||||
[migratorPassword],
|
|
||||||
);
|
|
||||||
await admin.query(alterMigrator.rows[0].sql);
|
|
||||||
const alterApp = await admin.query(
|
|
||||||
"SELECT format('ALTER ROLE krona_staff_app WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', $1::text) AS sql",
|
|
||||||
[appPassword],
|
|
||||||
);
|
|
||||||
await admin.query(alterApp.rows[0].sql);
|
|
||||||
}
|
|
||||||
await admin.query(
|
|
||||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
|
||||||
[database],
|
|
||||||
);
|
|
||||||
await admin.query(`DROP DATABASE IF EXISTS ${database}`);
|
|
||||||
await admin.query(
|
|
||||||
`CREATE DATABASE ${database} OWNER krona_migrator ENCODING 'UTF8' TEMPLATE template0`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await admin.end();
|
|
||||||
}
|
|
||||||
const dbAdminUrl = new URL(adminUrl);
|
|
||||||
dbAdminUrl.pathname = `/${database}`;
|
|
||||||
const dbAdmin = new pg.Client({ connectionString: dbAdminUrl.toString() });
|
|
||||||
await dbAdmin.connect();
|
|
||||||
try {
|
|
||||||
await dbAdmin.query('ALTER SCHEMA public OWNER TO krona_migrator');
|
|
||||||
await dbAdmin.query('REVOKE CREATE ON SCHEMA public FROM PUBLIC');
|
|
||||||
await dbAdmin.query(`REVOKE ALL ON DATABASE ${database} FROM PUBLIC`);
|
|
||||||
await dbAdmin.query(`GRANT CONNECT ON DATABASE ${database} TO krona_staff_app`);
|
|
||||||
} finally {
|
|
||||||
await dbAdmin.end();
|
|
||||||
}
|
|
||||||
if (keepRoles) {
|
|
||||||
console.log(`recreated database=${database} (roles kept, passwords unchanged)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const base = new URL(adminUrl);
|
|
||||||
const url = (user, password) => {
|
|
||||||
const u = new URL(base.toString());
|
|
||||||
u.username = user;
|
|
||||||
u.password = password;
|
|
||||||
u.pathname = `/${database}`;
|
|
||||||
return u.toString();
|
|
||||||
};
|
|
||||||
const migratorUrl = url('krona_migrator', migratorPassword);
|
|
||||||
const appUrl = url('krona_staff_app', appPassword);
|
|
||||||
const lines = [
|
|
||||||
`DATABASE_MIGRATION_URL=${migratorUrl}`,
|
|
||||||
`DATABASE_TEST_MIGRATION_URL=${migratorUrl}`,
|
|
||||||
`DATABASE_TEST_URL=${appUrl}`,
|
|
||||||
`DATABASE_URL=${appUrl}`,
|
|
||||||
`POSTGRES_APP_PASSWORD=${appPassword}`,
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
writeFileSync(envFile, lines.join('\n'), { mode: 0o600 });
|
|
||||||
// Проверяем атрибуты ролей и печатаем их (без паролей) как доказательство.
|
|
||||||
const check = new pg.Client({ connectionString: migratorUrl });
|
|
||||||
await check.connect();
|
|
||||||
try {
|
|
||||||
const roles = await check.query(
|
|
||||||
`SELECT rolname, rolsuper, rolcreaterole, rolcreatedb, rolbypassrls
|
|
||||||
FROM pg_roles WHERE rolname IN ('krona_migrator','krona_staff_app') ORDER BY rolname`,
|
|
||||||
);
|
|
||||||
const version = await check.query('SHOW server_version');
|
|
||||||
console.log(
|
|
||||||
`provisioned database=${database} server_version=${version.rows[0].server_version}`,
|
|
||||||
);
|
|
||||||
for (const r of roles.rows) {
|
|
||||||
console.log(
|
|
||||||
`role=${r.rolname} super=${r.rolsuper} createrole=${r.rolcreaterole} createdb=${r.rolcreatedb} bypassrls=${r.rolbypassrls}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await check.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await provision();
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// Запуск e2e с «храповиком» известных дефектов.
|
|
||||||
// * Сценарии проверяют ПРАВИЛЬНОЕ поведение, поэтому открытые дефекты дают красные тесты.
|
|
||||||
// * tests/e2e/known-defects.json — список ID, красный цвет которых сейчас ожидаем.
|
|
||||||
// * exit 0, если: все [HARNESS]-тесты зелёные И каждый красный тест есть в списке
|
|
||||||
// И ни один ID из списка не стал полностью зелёным (исправленный дефект нужно
|
|
||||||
// убрать из списка — иначе регрессию потом никто не заметит).
|
|
||||||
// * --no-known-defects: строгий режим, exit = код vitest (все дефекты красные).
|
|
||||||
// Полный вывод пишется в tests/e2e/evidence/e2e-run.txt, таблица — в e2e-table.md.
|
|
||||||
// tests/e2e/evidence/ не хранится в git (.gitignore): в CI это артефакт задачи e2e.
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { createWriteStream, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { nodeTool } from './node-commands.mjs';
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(import.meta.dirname, '../..');
|
|
||||||
const evidence = path.join(repoRoot, 'tests/e2e/evidence');
|
|
||||||
mkdirSync(evidence, { recursive: true });
|
|
||||||
const strict = process.argv.includes('--no-known-defects');
|
|
||||||
const jsonPath = path.join(evidence, 'e2e-results.json');
|
|
||||||
const logPath = path.join(evidence, 'e2e-run.txt');
|
|
||||||
const extra = process.argv.slice(2).filter((arg) => !arg.startsWith('--no-known-defects'));
|
|
||||||
|
|
||||||
const vitestArgs = [
|
|
||||||
'run',
|
|
||||||
'--config',
|
|
||||||
'tests/e2e/vitest.e2e.config.ts',
|
|
||||||
'--reporter=verbose',
|
|
||||||
'--reporter=json',
|
|
||||||
`--outputFile.json=${jsonPath}`,
|
|
||||||
...extra,
|
|
||||||
];
|
|
||||||
const log = createWriteStream(logPath);
|
|
||||||
const vitest = nodeTool('vitest', vitestArgs);
|
|
||||||
log.write(`$ node <vitest.mjs> ${vitestArgs.join(' ')}\n`);
|
|
||||||
const exitCode = await new Promise((resolve) => {
|
|
||||||
const child = spawn(vitest.command, vitest.args, { cwd: repoRoot, env: process.env });
|
|
||||||
for (const stream of [child.stdout, child.stderr]) {
|
|
||||||
stream.on('data', (chunk) => {
|
|
||||||
process.stdout.write(chunk);
|
|
||||||
log.write(chunk);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
child.on('close', (code) => resolve(code ?? 1));
|
|
||||||
});
|
|
||||||
log.end();
|
|
||||||
|
|
||||||
let results;
|
|
||||||
try {
|
|
||||||
results = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
console.error('[run-e2e] нет JSON-результатов: стенд не поднялся (см. e2e-run.txt)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const assertionCount = results.testResults?.reduce(
|
|
||||||
(total, file) => total + (file.assertionResults?.length ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
if (!assertionCount) {
|
|
||||||
console.error(
|
|
||||||
`[run-e2e] тесты не выполнены (vitest exit=${exitCode}): стенд не поднялся или global setup завершился ошибкой`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const known = strict
|
|
||||||
? {}
|
|
||||||
: JSON.parse(readFileSync(path.join(repoRoot, 'tests/e2e/known-defects.json'), 'utf8')).defects;
|
|
||||||
|
|
||||||
const byId = new Map();
|
|
||||||
for (const file of results.testResults) {
|
|
||||||
for (const test of file.assertionResults) {
|
|
||||||
const id = /^\[([A-Z0-9/-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED';
|
|
||||||
const entry = byId.get(id) ?? { passed: 0, failed: 0, tests: [] };
|
|
||||||
if (test.status === 'passed') entry.passed += 1;
|
|
||||||
else if (test.status === 'failed') entry.failed += 1;
|
|
||||||
entry.tests.push({ title: test.title, status: test.status });
|
|
||||||
byId.set(id, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const problems = [];
|
|
||||||
// Страховка: в evidence не должно попасть ничего похожего на секреты стенда
|
|
||||||
// (hex-токены 48/64 символа, токены устройств kstaff_dev_*, URL с паролем).
|
|
||||||
const secretPatterns = [
|
|
||||||
/\b[0-9a-f]{48}\b/,
|
|
||||||
/\b[0-9a-f]{64}\b/,
|
|
||||||
/kstaff_dev_[0-9A-Z]{26}_[0-9a-f]{16,}/,
|
|
||||||
/postgres:\/\/[^\s:@]+:[^\s@]+@/,
|
|
||||||
];
|
|
||||||
// Только файлы верхнего уровня: в подкаталогах (accept/ от accept-branch.mjs) свои логи с редакцией.
|
|
||||||
for (const entry of readdirSync(evidence, { withFileTypes: true })) {
|
|
||||||
if (!entry.isFile()) continue;
|
|
||||||
const name = entry.name;
|
|
||||||
const content = readFileSync(path.join(evidence, name), 'utf8');
|
|
||||||
for (const pattern of secretPatterns) {
|
|
||||||
if (pattern.test(content))
|
|
||||||
problems.push(`evidence/${name}: найдено похожее на секрет (${pattern})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const rows = [];
|
|
||||||
for (const [id, entry] of [...byId.entries()].sort()) {
|
|
||||||
const red = entry.failed > 0;
|
|
||||||
let verdict;
|
|
||||||
if (id === 'HARNESS' || id === 'UNTAGGED') {
|
|
||||||
verdict = red ? 'СТЕНД СЛОМАН' : 'зелёный (позитивный контроль)';
|
|
||||||
if (red || id === 'UNTAGGED') problems.push(`${id}: ${red ? 'красный' : 'тест без ID'}`);
|
|
||||||
} else if (red && known[id]) verdict = 'красный (известный дефект)';
|
|
||||||
else if (red) {
|
|
||||||
verdict = 'красный (НЕ в known-defects)';
|
|
||||||
problems.push(`${id}: новый красный тест — регрессия или новый дефект`);
|
|
||||||
} else if (known[id]) {
|
|
||||||
verdict = 'зелёный — ИСПРАВЛЕН, уберите из known-defects.json';
|
|
||||||
problems.push(`${id}: дефект исправлен, обновите known-defects.json`);
|
|
||||||
} else verdict = 'зелёный';
|
|
||||||
rows.push(
|
|
||||||
`| ${id} | ${red ? 'RED' : 'GREEN'} | ${entry.passed}/${entry.passed + entry.failed} | ${verdict} |`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const id of Object.keys(known)) {
|
|
||||||
if (!byId.has(id)) problems.push(`${id}: есть в known-defects.json, но сценария нет`);
|
|
||||||
}
|
|
||||||
const table = ['| ID | Цвет | Зелёных/всего | Вердикт |', '|---|---|---|---|', ...rows].join('\n');
|
|
||||||
writeFileSync(path.join(evidence, 'e2e-table.md'), `${table}\n`);
|
|
||||||
console.log(`\n[run-e2e] vitest exit=${exitCode}\n${table}`);
|
|
||||||
|
|
||||||
if (strict) {
|
|
||||||
if (problems.some((p) => p.startsWith('evidence/'))) {
|
|
||||||
console.error(`\n[run-e2e] FAIL:\n- ${problems.join('\n- ')}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
process.exit(exitCode);
|
|
||||||
}
|
|
||||||
if (problems.length > 0) {
|
|
||||||
console.error(`\n[run-e2e] FAIL:\n- ${problems.join('\n- ')}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('\n[run-e2e] OK: стенд работает, все красные сценарии — известные открытые дефекты');
|
|
||||||
|
|
@ -40,10 +40,7 @@ export class StandaloneIdentityAdapter implements IdentityPort {
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid standalone access token');
|
throw new Error('Invalid standalone access token');
|
||||||
}
|
}
|
||||||
} else if (access === 'manager') {
|
|
||||||
throw new Error('Standalone access tokens are not configured');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tenantId: parseUlid(this.config.STANDALONE_TENANT_ID),
|
tenantId: parseUlid(this.config.STANDALONE_TENANT_ID),
|
||||||
actorId: parseUlid(actorId),
|
actorId: parseUlid(actorId),
|
||||||
|
|
|
||||||
23
src/app.ts
23
src/app.ts
|
|
@ -1,4 +1,4 @@
|
||||||
import Fastify, { type FastifyError } from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import sensible from '@fastify/sensible';
|
import sensible from '@fastify/sensible';
|
||||||
|
|
@ -45,30 +45,9 @@ export async function buildApp({
|
||||||
const supplied = request.headers['x-correlation-id']?.toString();
|
const supplied = request.headers['x-correlation-id']?.toString();
|
||||||
return supplied && ulidPattern.test(supplied) ? supplied : newUlid();
|
return supplied && ulidPattern.test(supplied) ? supplied : newUlid();
|
||||||
},
|
},
|
||||||
ajv: {
|
|
||||||
customOptions: {
|
|
||||||
removeAdditional: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
app.setErrorHandler((error: FastifyError, request, reply) => {
|
|
||||||
const statusCode = typeof error.statusCode === 'number' ? error.statusCode : 500;
|
|
||||||
if (statusCode >= 500) {
|
|
||||||
request.log.error({ err: error }, 'Unhandled server error');
|
|
||||||
return reply.code(statusCode).send({
|
|
||||||
statusCode,
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Произошла внутренняя ошибка сервера.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return reply.code(statusCode).send({
|
|
||||||
statusCode,
|
|
||||||
error: error.name || 'Error',
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
app.addHook('onRequest', async (request, reply) => {
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
reply.header('x-correlation-id', request.id);
|
reply.header('x-correlation-id', request.id);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const PLACEHOLDER_TENANT_ID = '01J00000000000000000000000';
|
|
||||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
||||||
|
|
||||||
const configSchema = z.object({
|
const configSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||||
|
|
@ -26,9 +23,7 @@ const configSchema = z.object({
|
||||||
PLATFORM_JWT_ISSUER: optionalUrl(),
|
PLATFORM_JWT_ISSUER: optionalUrl(),
|
||||||
PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()),
|
PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()),
|
||||||
PLATFORM_JWKS_URL: optionalUrl(),
|
PLATFORM_JWKS_URL: optionalUrl(),
|
||||||
STANDALONE_TENANT_ID: z
|
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||||
.string({ error: 'STANDALONE_TENANT_ID is required and must be a ULID' })
|
|
||||||
.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
|
||||||
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||||
VAPID_PUBLIC_KEY: emptyStringAsUndefined(z.string().optional()),
|
VAPID_PUBLIC_KEY: emptyStringAsUndefined(z.string().optional()),
|
||||||
VAPID_PRIVATE_KEY: emptyStringAsUndefined(z.string().optional()),
|
VAPID_PRIVATE_KEY: emptyStringAsUndefined(z.string().optional()),
|
||||||
|
|
@ -47,17 +42,6 @@ export type AppConfig = z.infer<typeof configSchema>;
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
const config = configSchema.parse(env);
|
const config = configSchema.parse(env);
|
||||||
if (
|
|
||||||
config.STANDALONE_TENANT_ID === PLACEHOLDER_TENANT_ID &&
|
|
||||||
!(
|
|
||||||
config.NODE_ENV === 'test' ||
|
|
||||||
(config.NODE_ENV === 'development' && LOOPBACK_HOSTS.has(config.HOST.toLowerCase()))
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'STANDALONE_TENANT_ID uses the development placeholder, which is allowed only in development on loopback',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (Boolean(config.HTTPS_PFX_PATH) !== Boolean(config.HTTPS_PFX_PASSPHRASE)) {
|
if (Boolean(config.HTTPS_PFX_PATH) !== Boolean(config.HTTPS_PFX_PASSPHRASE)) {
|
||||||
throw new Error('HTTPS_PFX_PATH and HTTPS_PFX_PASSPHRASE must be configured together');
|
throw new Error('HTTPS_PFX_PATH and HTTPS_PFX_PASSPHRASE must be configured together');
|
||||||
}
|
}
|
||||||
|
|
@ -68,15 +52,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
||||||
}
|
}
|
||||||
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
|
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
|
||||||
if (
|
throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production');
|
||||||
!config.HTTPS_PFX_PATH ||
|
|
||||||
!config.STANDALONE_MANAGER_TOKEN ||
|
|
||||||
!config.STANDALONE_TERMINAL_TOKEN
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'STAFF_RUNTIME_MODE=standalone is not allowed in production without HTTPS and manager/terminal tokens',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (config.STAFF_RUNTIME_MODE === 'platform') {
|
if (config.STAFF_RUNTIME_MODE === 'platform') {
|
||||||
const required = [
|
const required = [
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,4 @@ export interface RequestContext {
|
||||||
permissions: ReadonlySet<string>;
|
permissions: ReadonlySet<string>;
|
||||||
correlationId: Ulid;
|
correlationId: Ulid;
|
||||||
causationId?: Ulid;
|
causationId?: Ulid;
|
||||||
role?: string;
|
|
||||||
orgUnitIds?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,6 @@ export interface PushSubscriptionInput {
|
||||||
user_agent?: string | undefined;
|
user_agent?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { isPrivateOrLoopbackHost } from './web-push.js';
|
|
||||||
|
|
||||||
export class PushValidationError extends Error {
|
|
||||||
readonly statusCode = 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PushConflictError extends Error {
|
|
||||||
readonly statusCode = 409;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PushDispatcherOptions {
|
export interface PushDispatcherOptions {
|
||||||
database: Database;
|
database: Database;
|
||||||
vapidKeys: VapidKeys;
|
vapidKeys: VapidKeys;
|
||||||
|
|
@ -87,28 +77,16 @@ export class PushDispatcher {
|
||||||
employeeId: string,
|
employeeId: string,
|
||||||
input: PushSubscriptionInput,
|
input: PushSubscriptionInput,
|
||||||
): Promise<{ id: string; endpoint: string }> {
|
): Promise<{ id: string; endpoint: string }> {
|
||||||
|
if (!input.endpoint.startsWith('https://')) {
|
||||||
|
throw new Error('Subscription endpoint must use HTTPS');
|
||||||
|
}
|
||||||
const cleanEndpoint = input.endpoint.trim();
|
const cleanEndpoint = input.endpoint.trim();
|
||||||
if (!cleanEndpoint.startsWith('https://')) {
|
|
||||||
throw new PushValidationError('Subscription endpoint must use HTTPS');
|
|
||||||
}
|
|
||||||
let parsedUrl: URL;
|
|
||||||
try {
|
|
||||||
parsedUrl = new URL(cleanEndpoint);
|
|
||||||
} catch {
|
|
||||||
throw new PushValidationError('Invalid endpoint URL');
|
|
||||||
}
|
|
||||||
if (parsedUrl.protocol !== 'https:' || isPrivateOrLoopbackHost(parsedUrl.hostname)) {
|
|
||||||
throw new PushValidationError('Subscription endpoint must be a public HTTPS address');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanP256dh = input.p256dh.trim();
|
const cleanP256dh = input.p256dh.trim();
|
||||||
const cleanAuth = input.auth.trim();
|
const cleanAuth = input.auth.trim();
|
||||||
const userAgent = (input.user_agent ?? '').trim();
|
const userAgent = (input.user_agent ?? '').trim();
|
||||||
|
|
||||||
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
||||||
throw new PushValidationError(
|
throw new Error('Endpoint, p256dh, and auth are required for WebPush subscription');
|
||||||
'Endpoint, p256dh, and auth are required for WebPush subscription',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionId = newUlid();
|
const subscriptionId = newUlid();
|
||||||
|
|
@ -120,29 +98,16 @@ export class PushDispatcher {
|
||||||
parsedTenantId,
|
parsedTenantId,
|
||||||
parsedActorId,
|
parsedActorId,
|
||||||
async (client) => {
|
async (client) => {
|
||||||
const existing = await client.query<{ id: string; employee_id: string }>(
|
|
||||||
`SELECT id, employee_id FROM staff_push_subscriptions WHERE tenant_id = $1 AND endpoint = $2`,
|
|
||||||
[parsedTenantId, cleanEndpoint],
|
|
||||||
);
|
|
||||||
if (existing.rowCount && existing.rows[0]) {
|
|
||||||
if (existing.rows[0].employee_id !== parsedEmployeeId) {
|
|
||||||
throw new PushConflictError(
|
|
||||||
'Subscription endpoint is already registered to another employee',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await client.query(
|
|
||||||
`UPDATE staff_push_subscriptions
|
|
||||||
SET p256dh = $3, auth = $4, user_agent = $5, updated_at = now()
|
|
||||||
WHERE tenant_id = $1 AND endpoint = $2`,
|
|
||||||
[parsedTenantId, cleanEndpoint, cleanP256dh, cleanAuth, userAgent],
|
|
||||||
);
|
|
||||||
return { id: existing.rows[0].id, endpoint: cleanEndpoint };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await client.query<{ id: string; endpoint: string }>(
|
const result = await client.query<{ id: string; endpoint: string }>(
|
||||||
`INSERT INTO staff_push_subscriptions
|
`INSERT INTO staff_push_subscriptions
|
||||||
(id, tenant_id, employee_id, endpoint, p256dh, auth, user_agent, updated_at)
|
(id, tenant_id, employee_id, endpoint, p256dh, auth, user_agent, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||||
|
ON CONFLICT (tenant_id, endpoint)
|
||||||
|
DO UPDATE SET employee_id = EXCLUDED.employee_id,
|
||||||
|
p256dh = EXCLUDED.p256dh,
|
||||||
|
auth = EXCLUDED.auth,
|
||||||
|
user_agent = EXCLUDED.user_agent,
|
||||||
|
updated_at = now()
|
||||||
RETURNING id, endpoint`,
|
RETURNING id, endpoint`,
|
||||||
[
|
[
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import net from 'node:net';
|
|
||||||
|
|
||||||
export interface VapidKeys {
|
export interface VapidKeys {
|
||||||
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
||||||
|
|
@ -213,46 +212,6 @@ export function encryptPushPayload(payload: string | Buffer, keys: PushSubscript
|
||||||
/**
|
/**
|
||||||
* Send an encrypted WebPush notification over HTTPS.
|
* Send an encrypted WebPush notification over HTTPS.
|
||||||
*/
|
*/
|
||||||
export function isPrivateOrLoopbackHost(host: string): boolean {
|
|
||||||
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
|
||||||
if (
|
|
||||||
h === 'localhost' ||
|
|
||||||
h.endsWith('.localhost') ||
|
|
||||||
h.endsWith('.local') ||
|
|
||||||
h.endsWith('.internal')
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const ipType = net.isIP(h);
|
|
||||||
if (ipType === 4) {
|
|
||||||
const parts = h.split('.').map(Number);
|
|
||||||
const [o1, o2] = parts;
|
|
||||||
if (o1 === 127 || o1 === 10 || o1 === 0) return true;
|
|
||||||
if (o1 === 172 && o2! >= 16 && o2! <= 31) return true;
|
|
||||||
if (o1 === 192 && o2! === 168) return true;
|
|
||||||
if (o1 === 169 && o2! === 254) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ipType === 6) {
|
|
||||||
if (h === '::1' || h === '::') return true;
|
|
||||||
if (
|
|
||||||
h.startsWith('fc') ||
|
|
||||||
h.startsWith('fd') ||
|
|
||||||
h.startsWith('fe8') ||
|
|
||||||
h.startsWith('fe9') ||
|
|
||||||
h.startsWith('fea') ||
|
|
||||||
h.startsWith('feb')
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (h.startsWith('::ffff:')) {
|
|
||||||
return isPrivateOrLoopbackHost(h.slice(7));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendWebPush(
|
export async function sendWebPush(
|
||||||
target: PushSubscriptionTarget,
|
target: PushSubscriptionTarget,
|
||||||
payload: string,
|
payload: string,
|
||||||
|
|
@ -263,22 +222,6 @@ export async function sendWebPush(
|
||||||
throw new Error('Subscription endpoint must use HTTPS');
|
throw new Error('Subscription endpoint must use HTTPS');
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetUrl: URL;
|
|
||||||
try {
|
|
||||||
targetUrl = new URL(target.endpoint);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid endpoint URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPrivateOrLoopbackHost(targetUrl.hostname)) {
|
|
||||||
return {
|
|
||||||
statusCode: 400,
|
|
||||||
successful: false,
|
|
||||||
shouldRemoveSubscription: true,
|
|
||||||
error: 'Cannot dispatch push to internal or private IP address',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptedBody = encryptPushPayload(payload, target.keys);
|
const encryptedBody = encryptPushPayload(payload, target.keys);
|
||||||
const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
|
const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
|
||||||
const fetchImpl = options?.fetchFn ?? fetch;
|
const fetchImpl = options?.fetchFn ?? fetch;
|
||||||
|
|
@ -294,7 +237,6 @@ export async function sendWebPush(
|
||||||
Urgency: options?.urgency ?? 'normal',
|
Urgency: options?.urgency ?? 'normal',
|
||||||
},
|
},
|
||||||
body: new Uint8Array(encryptedBody),
|
body: new Uint8Array(encryptedBody),
|
||||||
redirect: 'manual',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ export interface PayAssignmentInput {
|
||||||
pool_weight_bp?: number;
|
pool_weight_bp?: number;
|
||||||
effective_from?: string;
|
effective_from?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
params?: Record<string, number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PayAssignmentRecord {
|
export interface PayAssignmentRecord {
|
||||||
|
|
@ -295,14 +294,10 @@ export async function saveEmployeePayAssignment(
|
||||||
const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
|
const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
|
||||||
|
|
||||||
const params: Record<string, number> = {
|
const params: Record<string, number> = {
|
||||||
...(data.params ?? {}),
|
rate_minor: data.rate_minor ?? 10000,
|
||||||
...(data.rate_minor !== undefined ? { rate_minor: data.rate_minor } : {}),
|
percent_bp: data.percent_bp ?? 0,
|
||||||
...(data.percent_bp !== undefined ? { percent_bp: data.percent_bp } : {}),
|
guarantee_minor: data.guarantee_minor ?? 0,
|
||||||
...(data.guarantee_minor !== undefined ? { guarantee_minor: data.guarantee_minor } : {}),
|
|
||||||
};
|
};
|
||||||
if (params.rate_minor === undefined && !data.params) {
|
|
||||||
params.rate_minor = 10000;
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`SELECT pg_advisory_xact_lock(hashtextextended(
|
`SELECT pg_advisory_xact_lock(hashtextextended(
|
||||||
|
|
@ -479,22 +474,6 @@ export async function calculatePayrollPeriod(
|
||||||
AND s.business_date BETWEEN $2 AND $3
|
AND s.business_date BETWEEN $2 AND $3
|
||||||
AND s.status = 'confirmed'
|
AND s.status = 'confirmed'
|
||||||
AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4)
|
AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4)
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM staff_payroll_lines pl
|
|
||||||
JOIN staff_payroll_runs pr ON pr.tenant_id = pl.tenant_id AND pr.id = pl.run_id
|
|
||||||
WHERE pl.tenant_id = s.tenant_id
|
|
||||||
AND pl.employee_id = s.employee_id
|
|
||||||
AND pl.org_unit_id = s.org_unit_id
|
|
||||||
AND pl.business_date = s.business_date
|
|
||||||
AND pr.status IN ('approved', 'paid')
|
|
||||||
)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM staff_payroll_batch_lines bl
|
|
||||||
JOIN staff_payroll_batches pb ON pb.tenant_id = bl.tenant_id AND pb.id = bl.batch_id
|
|
||||||
WHERE bl.tenant_id = s.tenant_id
|
|
||||||
AND bl.work_session_id = s.id
|
|
||||||
AND pb.status = 'signed'
|
|
||||||
)
|
|
||||||
ORDER BY s.business_date, s.org_unit_id, s.employee_id`,
|
ORDER BY s.business_date, s.org_unit_id, s.employee_id`,
|
||||||
[tenantId, from, to, orgUnitId],
|
[tenantId, from, to, orgUnitId],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,7 @@ export function decodeCursor(tenantId: string, cursor: string): bigint {
|
||||||
candidateBuffer.length !== expectedBuffer.length ||
|
candidateBuffer.length !== expectedBuffer.length ||
|
||||||
!crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
|
!crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
|
||||||
) {
|
) {
|
||||||
throw new InvalidCursorError(
|
throw new InvalidCursorError('Подпись курсора синхронизации недействительна для данного арендатора');
|
||||||
'Подпись курсора синхронизации недействительна для данного арендатора',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return BigInt(seqStr);
|
return BigInt(seqStr);
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,7 @@ export class SyncService {
|
||||||
|
|
||||||
const tokenTenant = SyncService.parseTenantFromToken(rawToken);
|
const tokenTenant = SyncService.parseTenantFromToken(rawToken);
|
||||||
if (providedTenantId && tokenTenant && providedTenantId !== tokenTenant) {
|
if (providedTenantId && tokenTenant && providedTenantId !== tokenTenant) {
|
||||||
throw new SyncAuthError(
|
throw new SyncAuthError('Несоответствие арендатора в токене устройства', 'TENANT_MISMATCH', 403);
|
||||||
'Несоответствие арендатора в токене устройства',
|
|
||||||
'TENANT_MISMATCH',
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = providedTenantId ?? tokenTenant;
|
const tenantId = providedTenantId ?? tokenTenant;
|
||||||
|
|
@ -191,8 +187,7 @@ export class SyncService {
|
||||||
const rawToken = `kstaff_dev_${tenantId}_${rawSecret}`;
|
const rawToken = `kstaff_dev_${tenantId}_${rawSecret}`;
|
||||||
const tokenHash = SyncService.hashToken(rawToken);
|
const tokenHash = SyncService.hashToken(rawToken);
|
||||||
const tokenHint = rawToken.slice(0, 16);
|
const tokenHint = rawToken.slice(0, 16);
|
||||||
const scopes =
|
const scopes = params.allowed_scopes && params.allowed_scopes.length > 0
|
||||||
params.allowed_scopes && params.allowed_scopes.length > 0
|
|
||||||
? params.allowed_scopes
|
? params.allowed_scopes
|
||||||
: ['attendance:sync'];
|
: ['attendance:sync'];
|
||||||
|
|
||||||
|
|
@ -215,17 +210,7 @@ export class SyncService {
|
||||||
(id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by)
|
(id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
|
||||||
RETURNING id, tenant_id, device_code, name, token_hint, status, allowed_scopes, org_unit_id, registered_at, created_by`,
|
RETURNING id, tenant_id, device_code, name, token_hint, status, allowed_scopes, org_unit_id, registered_at, created_by`,
|
||||||
[
|
[deviceId, tenantId, params.device_code.trim(), params.name.trim(), tokenHash, tokenHint, scopes, orgUnitId, actorId],
|
||||||
deviceId,
|
|
||||||
tenantId,
|
|
||||||
params.device_code.trim(),
|
|
||||||
params.name.trim(),
|
|
||||||
tokenHash,
|
|
||||||
tokenHint,
|
|
||||||
scopes,
|
|
||||||
orgUnitId,
|
|
||||||
actorId,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const row = res.rows[0]!;
|
const row = res.rows[0]!;
|
||||||
|
|
@ -312,21 +297,6 @@ export class SyncService {
|
||||||
const seq = decodeCursor(tenantId, cursor);
|
const seq = decodeCursor(tenantId, cursor);
|
||||||
|
|
||||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
||||||
const maxSeqRes = await client.query<{ max_seq: string | null }>(
|
|
||||||
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
|
|
||||||
[tenantId],
|
|
||||||
);
|
|
||||||
if (maxSeqRes.rows.length > 0) {
|
|
||||||
const maxSeq = maxSeqRes.rows[0]?.max_seq ? BigInt(maxSeqRes.rows[0].max_seq) : 0n;
|
|
||||||
if (seq > maxSeq) {
|
|
||||||
throw new SyncValidationError(
|
|
||||||
`Курсор ${cursor} превышает текущую позицию журнала изменений (${maxSeq})`,
|
|
||||||
'CURSOR_AHEAD',
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await client.query<{ acknowledged_at: Date }>(
|
const res = await client.query<{ acknowledged_at: Date }>(
|
||||||
`INSERT INTO staff_sync_device_checkpoints
|
`INSERT INTO staff_sync_device_checkpoints
|
||||||
(tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor, acknowledged_at)
|
(tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor, acknowledged_at)
|
||||||
|
|
@ -349,34 +319,14 @@ export class SyncService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async pull(tenantId: Ulid, options?: PullOptions): Promise<SyncPullResult>;
|
async pull(tenantId: Ulid, deviceId: Ulid, options: PullOptions = {}): Promise<SyncPullResult> {
|
||||||
async pull(tenantId: Ulid, deviceId: Ulid, options?: PullOptions): Promise<SyncPullResult>;
|
|
||||||
async pull(
|
|
||||||
tenantId: Ulid,
|
|
||||||
deviceIdOrOptions?: Ulid | PullOptions,
|
|
||||||
maybeOptions: PullOptions = {},
|
|
||||||
): Promise<SyncPullResult> {
|
|
||||||
let deviceId: Ulid;
|
|
||||||
let options: PullOptions;
|
|
||||||
|
|
||||||
if (typeof deviceIdOrOptions === 'string') {
|
|
||||||
deviceId = deviceIdOrOptions;
|
|
||||||
options = maybeOptions ?? {};
|
|
||||||
} else {
|
|
||||||
deviceId = this.systemActorId;
|
|
||||||
options = deviceIdOrOptions ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = Math.min(Math.max(1, options.limit ?? 100), 100);
|
const limit = Math.min(Math.max(1, options.limit ?? 100), 100);
|
||||||
|
|
||||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
||||||
// 1. If cursor is provided and NOT a snapshot cursor -> incremental pull from staff_sync_change_log
|
// 1. If cursor is provided -> incremental pull from staff_sync_change_log
|
||||||
const isSnapshotCursor =
|
if (options.cursor) {
|
||||||
typeof options.cursor === 'string' && options.cursor.startsWith('snap_');
|
|
||||||
if (options.cursor && !isSnapshotCursor) {
|
|
||||||
const seq = decodeCursor(tenantId, options.cursor);
|
const seq = decodeCursor(tenantId, options.cursor);
|
||||||
const filterTypes =
|
const filterTypes = options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
||||||
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
|
||||||
|
|
||||||
const res = await client.query<{
|
const res = await client.query<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -410,8 +360,9 @@ export class SyncService {
|
||||||
created_at: r.created_at.toISOString(),
|
created_at: r.created_at.toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const nextCursor =
|
const nextCursor = changes.length > 0
|
||||||
changes.length > 0 ? changes[changes.length - 1]!.cursor : options.cursor;
|
? changes[changes.length - 1]!.cursor
|
||||||
|
: options.cursor;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changes,
|
changes,
|
||||||
|
|
@ -421,12 +372,6 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. If cursor is omitted/null -> initial bootstrap of current active snapshot
|
// 2. If cursor is omitted/null -> initial bootstrap of current active snapshot
|
||||||
let snapOffset = 0;
|
|
||||||
if (isSnapshotCursor) {
|
|
||||||
const match = options.cursor!.match(/^snap_(\d+)_/);
|
|
||||||
if (match) snapOffset = parseInt(match[1]!, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const seqRes = await client.query<{ max_seq: string | null }>(
|
const seqRes = await client.query<{ max_seq: string | null }>(
|
||||||
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
|
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
|
||||||
[tenantId],
|
[tenantId],
|
||||||
|
|
@ -435,29 +380,14 @@ export class SyncService {
|
||||||
const latestCursor = encodeCursor(tenantId, maxSeq);
|
const latestCursor = encodeCursor(tenantId, maxSeq);
|
||||||
|
|
||||||
const wanted = (type: string) =>
|
const wanted = (type: string) =>
|
||||||
!options.entity_types ||
|
!options.entity_types || options.entity_types.length === 0 || options.entity_types.includes(type);
|
||||||
options.entity_types.length === 0 ||
|
|
||||||
options.entity_types.includes(type);
|
|
||||||
|
|
||||||
const changes: SyncChange[] = [];
|
const changes: SyncChange[] = [];
|
||||||
|
|
||||||
// Org Units
|
// Org Units
|
||||||
if (wanted('org_unit')) {
|
if (wanted('org_unit')) {
|
||||||
const orgRes = await client.query<{
|
const orgRes = await client.query<{ id: string; code: string; name: string; status: string }>(
|
||||||
id: string;
|
`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`,
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
}>(
|
|
||||||
`SELECT id, code, name,
|
|
||||||
CASE
|
|
||||||
WHEN deleted_at IS NOT NULL THEN 'archived'
|
|
||||||
WHEN NOT is_active THEN 'inactive'
|
|
||||||
ELSE 'active'
|
|
||||||
END AS status
|
|
||||||
FROM staff_org_units
|
|
||||||
WHERE tenant_id = $1
|
|
||||||
ORDER BY id`,
|
|
||||||
[tenantId],
|
[tenantId],
|
||||||
);
|
);
|
||||||
for (const row of orgRes.rows) {
|
for (const row of orgRes.rows) {
|
||||||
|
|
@ -481,12 +411,7 @@ export class SyncService {
|
||||||
|
|
||||||
// Professions
|
// Professions
|
||||||
if (wanted('profession')) {
|
if (wanted('profession')) {
|
||||||
const profRes = await client.query<{
|
const profRes = await client.query<{ id: string; code: string; name: string; status: string }>(
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
}>(
|
|
||||||
`SELECT id, code, name, status FROM staff_professions WHERE tenant_id = $1 ORDER BY id`,
|
`SELECT id, code, name, status FROM staff_professions WHERE tenant_id = $1 ORDER BY id`,
|
||||||
[tenantId],
|
[tenantId],
|
||||||
);
|
);
|
||||||
|
|
@ -667,15 +592,12 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalChanges = changes.length;
|
const hasMore = changes.length > limit;
|
||||||
const pageChanges = changes.slice(snapOffset, snapOffset + limit);
|
const pageChanges = changes.slice(0, limit);
|
||||||
const nextOffset = snapOffset + limit;
|
|
||||||
const hasMore = nextOffset < totalChanges;
|
|
||||||
const nextCursor = hasMore ? `snap_${nextOffset}_${maxSeq}` : latestCursor;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changes: pageChanges,
|
changes: pageChanges,
|
||||||
next_cursor: nextCursor,
|
next_cursor: latestCursor,
|
||||||
has_more: hasMore,
|
has_more: hasMore,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -740,8 +662,7 @@ export class SyncService {
|
||||||
command_type: cmd.command_type,
|
command_type: cmd.command_type,
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
||||||
error_message:
|
error_message: 'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
|
||||||
'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
|
|
||||||
};
|
};
|
||||||
await this.saveInboxCommand(client, tenantId, device.id, cmd, rejectedResult);
|
await this.saveInboxCommand(client, tenantId, device.id, cmd, rejectedResult);
|
||||||
results.push(rejectedResult);
|
results.push(rejectedResult);
|
||||||
|
|
@ -777,25 +698,7 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Execute command
|
// 4. Execute command
|
||||||
await client.query('SAVEPOINT cmd');
|
const executionResult = await this.executeCommand(client, tenantId, device, cmd);
|
||||||
let executionResult: SyncCommandResult;
|
|
||||||
try {
|
|
||||||
executionResult = await this.executeCommand(client, tenantId, device, cmd);
|
|
||||||
if (executionResult.status === 'rejected') {
|
|
||||||
await client.query('ROLLBACK TO SAVEPOINT cmd');
|
|
||||||
} else {
|
|
||||||
await client.query('RELEASE SAVEPOINT cmd');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK TO SAVEPOINT cmd');
|
|
||||||
executionResult = {
|
|
||||||
idempotency_key: cmd.idempotency_key,
|
|
||||||
command_type: cmd.command_type,
|
|
||||||
status: 'rejected',
|
|
||||||
error_code: 'COMMAND_EXECUTION_ERROR',
|
|
||||||
error_message: error instanceof Error ? error.message : 'Ошибка выполнения команды',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await this.saveInboxCommand(client, tenantId, device.id, cmd, executionResult);
|
await this.saveInboxCommand(client, tenantId, device.id, cmd, executionResult);
|
||||||
results.push(executionResult);
|
results.push(executionResult);
|
||||||
}
|
}
|
||||||
|
|
@ -819,6 +722,7 @@ export class SyncService {
|
||||||
const eventType = payload.event_type as string;
|
const eventType = payload.event_type as string;
|
||||||
const occurredAt = payload.occurred_at as string;
|
const occurredAt = payload.occurred_at as string;
|
||||||
const sourceEventId = (payload.source_event_id as string) || cmd.idempotency_key;
|
const sourceEventId = (payload.source_event_id as string) || cmd.idempotency_key;
|
||||||
|
const orgUnitId = (payload.org_unit_id as string) || device.org_unit_id;
|
||||||
|
|
||||||
if (!employeeId || !['check_in', 'check_out'].includes(eventType) || !occurredAt) {
|
if (!employeeId || !['check_in', 'check_out'].includes(eventType) || !occurredAt) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -830,35 +734,9 @@ export class SyncService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate timestamp window: -180 days .. +5 min
|
// Verify employee belongs to tenant
|
||||||
const parsedOccurred = new Date(occurredAt);
|
|
||||||
const occurredMs = parsedOccurred.getTime();
|
|
||||||
if (isNaN(occurredMs)) {
|
|
||||||
return {
|
|
||||||
idempotency_key: cmd.idempotency_key,
|
|
||||||
command_type: cmd.command_type,
|
|
||||||
status: 'rejected',
|
|
||||||
error_code: 'INVALID_TIMESTAMP',
|
|
||||||
error_message: 'Некорректный формат времени отметки',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const maxPast = 180 * 24 * 60 * 60 * 1000;
|
|
||||||
const maxFuture = 5 * 60 * 1000;
|
|
||||||
const diff = Date.now() - occurredMs;
|
|
||||||
if (diff > maxPast || diff < -maxFuture) {
|
|
||||||
return {
|
|
||||||
idempotency_key: cmd.idempotency_key,
|
|
||||||
command_type: cmd.command_type,
|
|
||||||
status: 'rejected',
|
|
||||||
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
|
||||||
error_message:
|
|
||||||
'Время отметки находится вне допустимого диапазона (-180 дней .. +5 минут)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify employee belongs to tenant and is active
|
|
||||||
const empCheck = await client.query<{ id: string }>(
|
const empCheck = await client.query<{ id: string }>(
|
||||||
`SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2 AND status = 'active'`,
|
`SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2`,
|
||||||
[tenantId, employeeId],
|
[tenantId, employeeId],
|
||||||
);
|
);
|
||||||
if (empCheck.rowCount === 0) {
|
if (empCheck.rowCount === 0) {
|
||||||
|
|
@ -866,45 +744,20 @@ export class SyncService {
|
||||||
idempotency_key: cmd.idempotency_key,
|
idempotency_key: cmd.idempotency_key,
|
||||||
command_type: cmd.command_type,
|
command_type: cmd.command_type,
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
error_code: 'EMPLOYEE_INACTIVE',
|
error_code: 'EMPLOYEE_NOT_FOUND',
|
||||||
error_message: 'Сотрудник не найден или не активен',
|
error_message: 'Сотрудник не найден в данном предприятии',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check org_unit_id: payload cannot override foreign device org_unit_id
|
|
||||||
const payloadOrgUnitId =
|
|
||||||
typeof payload.org_unit_id === 'string' ? payload.org_unit_id : null;
|
|
||||||
if (payloadOrgUnitId && device.org_unit_id && payloadOrgUnitId !== device.org_unit_id) {
|
|
||||||
return {
|
|
||||||
idempotency_key: cmd.idempotency_key,
|
|
||||||
command_type: cmd.command_type,
|
|
||||||
status: 'rejected',
|
|
||||||
error_code: 'ORG_UNIT_MISMATCH',
|
|
||||||
error_message: 'Точка в отметке не соответствует подразделению устройства',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const effectiveOrgUnitId = device.org_unit_id ?? payloadOrgUnitId;
|
|
||||||
|
|
||||||
const eventId = (payload.event_id as string) || newUlid();
|
const eventId = (payload.event_id as string) || newUlid();
|
||||||
const correlationId = cmd.correlation_id || newUlid();
|
const correlationId = cmd.correlation_id || newUlid();
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_attendance_events
|
`INSERT INTO staff_attendance_events
|
||||||
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id, org_unit_id)
|
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id)
|
||||||
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
|
||||||
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
|
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
|
||||||
[
|
[eventId, tenantId, employeeId, eventType, sourceEventId, occurredAt, device.id, device.id, correlationId],
|
||||||
eventId,
|
|
||||||
tenantId,
|
|
||||||
employeeId,
|
|
||||||
eventType,
|
|
||||||
sourceEventId,
|
|
||||||
occurredAt,
|
|
||||||
device.id,
|
|
||||||
device.id,
|
|
||||||
correlationId,
|
|
||||||
effectiveOrgUnitId,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const safePayload: SafeAttendanceEventPayload = {
|
const safePayload: SafeAttendanceEventPayload = {
|
||||||
|
|
@ -914,19 +767,11 @@ export class SyncService {
|
||||||
occurred_at: occurredAt,
|
occurred_at: occurredAt,
|
||||||
source_event_id: sourceEventId,
|
source_event_id: sourceEventId,
|
||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
org_unit_id: effectiveOrgUnitId,
|
org_unit_id: orgUnitId ? String(orgUnitId) : null,
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.recordChange(
|
await this.recordChange(client, tenantId, 'attendance_event', eventId, 'upsert', 1, safePayload);
|
||||||
client,
|
|
||||||
tenantId,
|
|
||||||
'attendance_event',
|
|
||||||
eventId,
|
|
||||||
'upsert',
|
|
||||||
1,
|
|
||||||
safePayload,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
idempotency_key: cmd.idempotency_key,
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
|
@ -936,15 +781,207 @@ export class SyncService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'upsert_work_session':
|
case 'upsert_work_session': {
|
||||||
case 'upsert_timesheet_entry': {
|
const sessionId = (payload.id as string) || newUlid();
|
||||||
|
const employeeId = payload.employee_id as string;
|
||||||
|
const orgUnitId = (payload.org_unit_id as string) || device.org_unit_id;
|
||||||
|
const businessDate = payload.business_date as string;
|
||||||
|
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
|
||||||
|
const startedAt = payload.started_at as string | null | undefined;
|
||||||
|
const endedAt = payload.ended_at as string | null | undefined;
|
||||||
|
const workedMinutes = typeof payload.worked_minutes === 'number' ? payload.worked_minutes : 0;
|
||||||
|
const sessionStatus = (payload.status as string) || 'open';
|
||||||
|
|
||||||
|
if (!employeeId || !orgUnitId || !businessDate) {
|
||||||
return {
|
return {
|
||||||
idempotency_key: cmd.idempotency_key,
|
idempotency_key: cmd.idempotency_key,
|
||||||
command_type: cmd.command_type,
|
command_type: cmd.command_type,
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
error_code: 'COMMAND_NOT_SUPPORTED',
|
error_code: 'INVALID_WORK_SESSION_PAYLOAD',
|
||||||
error_message:
|
error_message: 'employee_id, org_unit_id и business_date обязательны',
|
||||||
'Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено',
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
|
||||||
|
`SELECT id, revision, status FROM staff_work_sessions WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[tenantId, sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRes.rows.length > 0) {
|
||||||
|
const existing = existingRes.rows[0]!;
|
||||||
|
if (existing.revision !== clientRevision) {
|
||||||
|
const conflict: SyncConflict = {
|
||||||
|
conflict_code: 'REVISION_MISMATCH',
|
||||||
|
entity_type: 'work_session',
|
||||||
|
entity_id: sessionId,
|
||||||
|
expected_revision: clientRevision,
|
||||||
|
actual_revision: existing.revision,
|
||||||
|
safe_description: 'Ревизия рабочей смены на сервере изменилась',
|
||||||
|
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'conflict',
|
||||||
|
conflict,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRev = existing.revision + 1;
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_work_sessions
|
||||||
|
SET started_at = $1, ended_at = $2, worked_minutes = $3, status = $4, revision = $5, updated_at = now()
|
||||||
|
WHERE tenant_id = $6 AND id = $7`,
|
||||||
|
[startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, nextRev, tenantId, sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const safePayload: SafeWorkSessionPayload = {
|
||||||
|
id: sessionId,
|
||||||
|
employee_id: employeeId,
|
||||||
|
org_unit_id: orgUnitId,
|
||||||
|
business_date: businessDate,
|
||||||
|
started_at: startedAt ?? null,
|
||||||
|
ended_at: endedAt ?? null,
|
||||||
|
worked_minutes: workedMinutes,
|
||||||
|
status: sessionStatus,
|
||||||
|
revision: nextRev,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', nextRev, safePayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'applied',
|
||||||
|
result: { id: sessionId, revision: nextRev, status: 'applied' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// New work session insert
|
||||||
|
const newRev = clientRevision;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_work_sessions
|
||||||
|
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, revision)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
|
[sessionId, tenantId, employeeId, orgUnitId, businessDate, startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, newRev],
|
||||||
|
);
|
||||||
|
|
||||||
|
const safePayload: SafeWorkSessionPayload = {
|
||||||
|
id: sessionId,
|
||||||
|
employee_id: employeeId,
|
||||||
|
org_unit_id: orgUnitId,
|
||||||
|
business_date: businessDate,
|
||||||
|
started_at: startedAt ?? null,
|
||||||
|
ended_at: endedAt ?? null,
|
||||||
|
worked_minutes: workedMinutes,
|
||||||
|
status: sessionStatus,
|
||||||
|
revision: newRev,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', newRev, safePayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'applied',
|
||||||
|
result: { id: sessionId, revision: newRev, status: 'applied' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'upsert_timesheet_entry': {
|
||||||
|
const entryId = (payload.id as string) || newUlid();
|
||||||
|
const employeeId = payload.employee_id as string;
|
||||||
|
const workDate = payload.work_date as string;
|
||||||
|
const minutesWorked = typeof payload.minutes_worked === 'number' ? payload.minutes_worked : 0;
|
||||||
|
const tsStatus = (payload.status as string) || 'draft';
|
||||||
|
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
|
||||||
|
|
||||||
|
if (!employeeId || !workDate) {
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'rejected',
|
||||||
|
error_code: 'INVALID_TIMESHEET_PAYLOAD',
|
||||||
|
error_message: 'employee_id и work_date обязательны',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
|
||||||
|
`SELECT id, revision, status FROM staff_timesheet_entries WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[tenantId, entryId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRes.rows.length > 0) {
|
||||||
|
const existing = existingRes.rows[0]!;
|
||||||
|
if (existing.revision !== clientRevision) {
|
||||||
|
const conflict: SyncConflict = {
|
||||||
|
conflict_code: 'REVISION_MISMATCH',
|
||||||
|
entity_type: 'timesheet_entry',
|
||||||
|
entity_id: entryId,
|
||||||
|
expected_revision: clientRevision,
|
||||||
|
actual_revision: existing.revision,
|
||||||
|
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
||||||
|
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'conflict',
|
||||||
|
conflict,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRev = existing.revision + 1;
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_timesheet_entries
|
||||||
|
SET minutes_worked = $1, status = $2, revision = $3, updated_at = now()
|
||||||
|
WHERE tenant_id = $4 AND id = $5`,
|
||||||
|
[minutesWorked, tsStatus, nextRev, tenantId, entryId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const safePayload: SafeTimesheetEntryPayload = {
|
||||||
|
id: entryId,
|
||||||
|
employee_id: employeeId,
|
||||||
|
work_date: workDate,
|
||||||
|
minutes_worked: minutesWorked,
|
||||||
|
status: tsStatus,
|
||||||
|
revision: nextRev,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', nextRev, safePayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'applied',
|
||||||
|
result: { id: entryId, revision: nextRev, status: 'applied' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRev = clientRevision;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_timesheet_entries
|
||||||
|
(id, tenant_id, employee_id, work_date, minutes_worked, status, revision, updated_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[entryId, tenantId, employeeId, workDate, minutesWorked, tsStatus, newRev, device.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const safePayload: SafeTimesheetEntryPayload = {
|
||||||
|
id: entryId,
|
||||||
|
employee_id: employeeId,
|
||||||
|
work_date: workDate,
|
||||||
|
minutes_worked: minutesWorked,
|
||||||
|
status: tsStatus,
|
||||||
|
revision: newRev,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', newRev, safePayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'applied',
|
||||||
|
result: { id: entryId, revision: newRev, status: 'applied' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1015,13 +1052,8 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cleanupRetention(
|
async cleanupRetention(retentionDays = 30): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
||||||
retentionDays = 30,
|
return this.database.tenantTransaction(this.systemActorId, this.systemActorId, async (client) => {
|
||||||
): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
|
||||||
return this.database.tenantTransaction(
|
|
||||||
this.systemActorId,
|
|
||||||
this.systemActorId,
|
|
||||||
async (client) => {
|
|
||||||
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
||||||
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
||||||
[retentionDays],
|
[retentionDays],
|
||||||
|
|
@ -1031,7 +1063,6 @@ export class SyncService {
|
||||||
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
||||||
purged_commands: Number(row?.purged_commands ?? 0),
|
purged_commands: Number(row?.purged_commands ?? 0),
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,12 +131,6 @@ export async function registerOperationsRoutes(
|
||||||
api.get('/org-units', async (request) => {
|
api.get('/org-units', async (request) => {
|
||||||
const context = await auth.resolve(request);
|
const context = await auth.resolve(request);
|
||||||
const canManage = context.permissions.has('staff:org_unit:read');
|
const canManage = context.permissions.has('staff:org_unit:read');
|
||||||
if (!canManage && !context.permissions.has('staff:employee:read')) {
|
|
||||||
throw request.server.httpErrors.forbidden(
|
|
||||||
'Недостаточно прав для просмотра списка подразделений.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||||
const rows = await client.query(
|
const rows = await client.query(
|
||||||
`SELECT DISTINCT u.id, u.code, u.name, u.timezone
|
`SELECT DISTINCT u.id, u.code, u.name, u.timezone
|
||||||
|
|
@ -575,23 +569,18 @@ export async function registerOperationsRoutes(
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
|
||||||
required: ['pay_rule_id'],
|
required: ['pay_rule_id'],
|
||||||
properties: {
|
properties: {
|
||||||
org_unit_id: {
|
org_unit_id: {
|
||||||
anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }],
|
anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }],
|
||||||
},
|
},
|
||||||
pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
rate_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
rate_minor: { type: 'integer', minimum: 0 },
|
||||||
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
||||||
guarantee_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
guarantee_minor: { type: 'integer', minimum: 0 },
|
||||||
pool_weight_bp: { type: 'integer', minimum: 0, maximum: 20000 },
|
pool_weight_bp: { type: 'integer', minimum: 0, maximum: 20000 },
|
||||||
effective_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
effective_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
||||||
note: { type: 'string', maxLength: 500 },
|
note: { type: 'string', maxLength: 500 },
|
||||||
params: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -671,23 +660,9 @@ export async function registerOperationsRoutes(
|
||||||
auth.assertMutationOrigin(request);
|
auth.assertMutationOrigin(request);
|
||||||
const context = await auth.require(request, 'staff:payroll:calculate');
|
const context = await auth.require(request, 'staff:payroll:calculate');
|
||||||
|
|
||||||
if (context.role === 'point_manager') {
|
|
||||||
if (
|
|
||||||
!request.body.org_unit_id ||
|
|
||||||
!(context.orgUnitIds ?? []).includes(request.body.org_unit_id)
|
|
||||||
) {
|
|
||||||
return reply.forbidden(
|
|
||||||
'Менеджер точки может рассчитывать зарплату только для своей точки.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let from: string;
|
let from: string;
|
||||||
let to: string;
|
let to: string;
|
||||||
if (request.body.month) {
|
if (request.body.month) {
|
||||||
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(request.body.month)) {
|
|
||||||
return reply.badRequest('Некорректный формат месяца (ожидается YYYY-MM от 01 до 12).');
|
|
||||||
}
|
|
||||||
from = `${request.body.month}-01`;
|
from = `${request.body.month}-01`;
|
||||||
const parts = request.body.month.split('-').map(Number);
|
const parts = request.body.month.split('-').map(Number);
|
||||||
const year = parts[0]!;
|
const year = parts[0]!;
|
||||||
|
|
@ -798,53 +773,6 @@ export async function registerOperationsRoutes(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
api.get<{ Params: { runId: string } }>(
|
|
||||||
'/payroll/runs/:runId/verify',
|
|
||||||
{
|
|
||||||
schema: {
|
|
||||||
params: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['runId'],
|
|
||||||
properties: {
|
|
||||||
runId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async (request, reply) => {
|
|
||||||
const context = await auth.resolve(request);
|
|
||||||
if (!context.permissions.has('staff:payroll:read')) {
|
|
||||||
return reply.forbidden('Недостаточно прав для проверки расчёта.');
|
|
||||||
}
|
|
||||||
const runId = parseUlid(request.params.runId);
|
|
||||||
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
|
||||||
const runRes = await client.query<{ total_accrued_minor: string }>(
|
|
||||||
`SELECT total_accrued_minor::text AS total_accrued_minor
|
|
||||||
FROM staff_payroll_runs
|
|
||||||
WHERE tenant_id = $1 AND id = $2`,
|
|
||||||
[context.tenantId, runId],
|
|
||||||
);
|
|
||||||
if (runRes.rowCount === 0 || !runRes.rows[0]) {
|
|
||||||
return reply.notFound('Расчёт зарплаты не найден.');
|
|
||||||
}
|
|
||||||
const linesRes = await client.query<{ lines_total: string }>(
|
|
||||||
`SELECT COALESCE(SUM(amount_minor), 0)::text AS lines_total
|
|
||||||
FROM staff_payroll_lines
|
|
||||||
WHERE tenant_id = $1 AND run_id = $2`,
|
|
||||||
[context.tenantId, runId],
|
|
||||||
);
|
|
||||||
const storedTotal = Number(runRes.rows[0].total_accrued_minor);
|
|
||||||
const recalculatedTotal = Number(linesRes.rows[0]?.lines_total ?? 0);
|
|
||||||
return reply.send({
|
|
||||||
run_id: runId,
|
|
||||||
stored_total_minor: storedTotal,
|
|
||||||
recalculated_total_minor: recalculatedTotal,
|
|
||||||
matches: storedTotal === recalculatedTotal,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
api.post<{ Body: OwnerWorkbookImportBody }>(
|
api.post<{ Body: OwnerWorkbookImportBody }>(
|
||||||
'/imports/owner-workbook',
|
'/imports/owner-workbook',
|
||||||
{
|
{
|
||||||
|
|
@ -1090,20 +1018,18 @@ export async function registerOperationsRoutes(
|
||||||
const desiredAmount =
|
const desiredAmount =
|
||||||
entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor;
|
entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor;
|
||||||
const employeeId = employeeIds.get(entry.employee_external_ref);
|
const employeeId = employeeIds.get(entry.employee_external_ref);
|
||||||
|
const legacyPrefix = `xlsx:${entry.kind}:%`;
|
||||||
|
const currentPrefix = `xlsx:payroll:${entry.kind}:%`;
|
||||||
const existing = await client.query<{ amount_minor: string }>(
|
const existing = await client.query<{ amount_minor: string }>(
|
||||||
`SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor
|
`SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor
|
||||||
FROM staff_payroll_ledger
|
FROM staff_payroll_ledger
|
||||||
WHERE tenant_id = $1 AND employee_id = $2
|
WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3
|
||||||
AND (external_key = $3 OR external_key LIKE $3 || ':%')`,
|
AND (external_key LIKE $4 OR external_key LIKE $5)`,
|
||||||
[context.tenantId, employeeId, entry.external_key],
|
[context.tenantId, employeeId, entry.business_date, legacyPrefix, currentPrefix],
|
||||||
);
|
);
|
||||||
const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0);
|
const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0);
|
||||||
const delta = desiredAmount - previousAmount;
|
const delta = desiredAmount - previousAmount;
|
||||||
if (delta === 0) continue;
|
if (delta === 0) continue;
|
||||||
const isInitial = previousAmount === 0;
|
|
||||||
const rowKey = isInitial
|
|
||||||
? entry.external_key
|
|
||||||
: `${entry.external_key}:adj:${newUlid()}`;
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_payroll_ledger
|
`INSERT INTO staff_payroll_ledger
|
||||||
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
||||||
|
|
@ -1115,12 +1041,12 @@ export async function registerOperationsRoutes(
|
||||||
context.tenantId,
|
context.tenantId,
|
||||||
employeeId,
|
employeeId,
|
||||||
entry.business_date,
|
entry.business_date,
|
||||||
isInitial ? entry.kind : 'adjustment',
|
previousAmount === 0 ? entry.kind : 'adjustment',
|
||||||
delta,
|
delta,
|
||||||
context.correlationId,
|
context.correlationId,
|
||||||
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
|
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
|
||||||
context.actorId,
|
context.actorId,
|
||||||
rowKey,
|
entry.external_key,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1276,8 +1202,7 @@ function validateImport(body: OwnerWorkbookImportBody): boolean {
|
||||||
if (
|
if (
|
||||||
!entry.external_key ||
|
!entry.external_key ||
|
||||||
!parseDate(entry.business_date) ||
|
!parseDate(entry.business_date) ||
|
||||||
!Number.isSafeInteger(entry.amount_minor) ||
|
!Number.isSafeInteger(entry.amount_minor)
|
||||||
!['accrual', 'payout', 'adjustment'].includes(entry.kind)
|
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
944
src/portal.ts
944
src/portal.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,31 +0,0 @@
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
export interface SessionData {
|
|
||||||
token: string;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionStore = new Map<string, SessionData>();
|
|
||||||
|
|
||||||
export function createSession(token: string, maxAgeSeconds: number): string {
|
|
||||||
const sessionId = randomBytes(32).toString('hex');
|
|
||||||
sessionStore.set(sessionId, {
|
|
||||||
token,
|
|
||||||
expiresAt: Date.now() + maxAgeSeconds * 1000,
|
|
||||||
});
|
|
||||||
return sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSessionToken(sessionId: string): string | undefined {
|
|
||||||
const session = sessionStore.get(sessionId);
|
|
||||||
if (!session) return undefined;
|
|
||||||
if (session.expiresAt <= Date.now()) {
|
|
||||||
sessionStore.delete(sessionId);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return session.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function revokeSession(sessionId: string): void {
|
|
||||||
sessionStore.delete(sessionId);
|
|
||||||
}
|
|
||||||
82
src/sync.ts
82
src/sync.ts
|
|
@ -4,10 +4,13 @@ import { parseUlid, type Ulid } from './core/ids.js';
|
||||||
import type { Database } from './infrastructure/database.js';
|
import type { Database } from './infrastructure/database.js';
|
||||||
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
|
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
|
||||||
import type { IdentityPort } from './core/ports.js';
|
import type { IdentityPort } from './core/ports.js';
|
||||||
import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
|
import {
|
||||||
|
SyncAuthError,
|
||||||
|
SyncService,
|
||||||
|
SyncValidationError,
|
||||||
|
} from './domain/sync/service.js';
|
||||||
import { InvalidCursorError } from './domain/sync/cursor.js';
|
import { InvalidCursorError } from './domain/sync/cursor.js';
|
||||||
import type { SyncCommand } from './domain/sync/types.js';
|
import type { SyncCommand } from './domain/sync/types.js';
|
||||||
import { getSessionToken } from './session.js';
|
|
||||||
|
|
||||||
interface PullBody {
|
interface PullBody {
|
||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
|
|
@ -68,25 +71,7 @@ export async function registerSyncRoutes(
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
function assertCookieMutationOrigin(request: FastifyRequest) {
|
|
||||||
const authorization = request.headers.authorization;
|
|
||||||
if (authorization?.startsWith('Bearer ')) return;
|
|
||||||
const cookie = request.headers.cookie;
|
|
||||||
if (!cookie || !cookie.includes('__Host-krona_staff_session=')) return;
|
|
||||||
const origin = request.headers.origin;
|
|
||||||
if (!origin) {
|
|
||||||
throw request.server.httpErrors.forbidden('Запрос без Origin отклонён.');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = new URL(origin);
|
|
||||||
if (parsed.host !== request.headers.host) throw new Error();
|
|
||||||
} catch {
|
|
||||||
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requireManagerContext = async (request: FastifyRequest) => {
|
const requireManagerContext = async (request: FastifyRequest) => {
|
||||||
assertCookieMutationOrigin(request);
|
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
const cookieHeader = request.headers.cookie;
|
const cookieHeader = request.headers.cookie;
|
||||||
const sessionCookieName = '__Host-krona_staff_session';
|
const sessionCookieName = '__Host-krona_staff_session';
|
||||||
|
|
@ -97,8 +82,7 @@ export async function registerSyncRoutes(
|
||||||
const sep = part.indexOf('=');
|
const sep = part.indexOf('=');
|
||||||
if (sep > 0 && part.slice(0, sep).trim() === sessionCookieName) {
|
if (sep > 0 && part.slice(0, sep).trim() === sessionCookieName) {
|
||||||
try {
|
try {
|
||||||
const rawSessionId = decodeURIComponent(part.slice(sep + 1).trim());
|
token = decodeURIComponent(part.slice(sep + 1).trim());
|
||||||
token = getSessionToken(rawSessionId);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed cookie
|
// ignore malformed cookie
|
||||||
}
|
}
|
||||||
|
|
@ -107,30 +91,20 @@ export async function registerSyncRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw request.server.httpErrors.unauthorized(
|
|
||||||
'Требуется действующий ключ доступа руководителя',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await baseIdentity.resolve(token, parseUlid(request.id));
|
const context = await baseIdentity.resolve(token, parseUlid(request.id));
|
||||||
if (
|
if (
|
||||||
!context.permissions.has('staff:attendance:manage') &&
|
!context.permissions.has('staff:attendance:manage') &&
|
||||||
!context.permissions.has('staff:sync:manage')
|
!context.permissions.has('staff:sync:manage')
|
||||||
) {
|
) {
|
||||||
throw request.server.httpErrors.forbidden(
|
throw request.server.httpErrors.forbidden('Недостаточно прав для управления устройствами синхронизации');
|
||||||
'Недостаточно прав для управления устройствами синхронизации',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
|
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw request.server.httpErrors.unauthorized(
|
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
|
||||||
'Требуется действующий ключ доступа руководителя',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -195,11 +169,7 @@ export async function registerSyncRoutes(
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const manager = await requireManagerContext(request);
|
const manager = await requireManagerContext(request);
|
||||||
const result = await syncService.registerDevice(
|
const result = await syncService.registerDevice(manager.tenantId, manager.actorId, request.body);
|
||||||
manager.tenantId,
|
|
||||||
manager.actorId,
|
|
||||||
request.body,
|
|
||||||
);
|
|
||||||
return reply.code(201).send(result);
|
return reply.code(201).send(result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -287,8 +257,7 @@ export async function registerSyncRoutes(
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Sync'],
|
tags: ['Sync'],
|
||||||
summary:
|
summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
||||||
'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
|
||||||
body: {
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -310,15 +279,7 @@ export async function registerSyncRoutes(
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: [
|
required: ['cursor', 'entity_type', 'entity_id', 'operation', 'revision', 'payload', 'created_at'],
|
||||||
'cursor',
|
|
||||||
'entity_type',
|
|
||||||
'entity_id',
|
|
||||||
'operation',
|
|
||||||
'revision',
|
|
||||||
'payload',
|
|
||||||
'created_at',
|
|
||||||
],
|
|
||||||
properties: {
|
properties: {
|
||||||
cursor: { type: 'string' },
|
cursor: { type: 'string' },
|
||||||
entity_type: { type: 'string' },
|
entity_type: { type: 'string' },
|
||||||
|
|
@ -369,14 +330,7 @@ export async function registerSyncRoutes(
|
||||||
maxItems: 100,
|
maxItems: 100,
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: [
|
required: ['command_type', 'schema_version', 'idempotency_key', 'correlation_id', 'created_at', 'payload'],
|
||||||
'command_type',
|
|
||||||
'schema_version',
|
|
||||||
'idempotency_key',
|
|
||||||
'correlation_id',
|
|
||||||
'created_at',
|
|
||||||
'payload',
|
|
||||||
],
|
|
||||||
properties: {
|
properties: {
|
||||||
command_type: { type: 'string' },
|
command_type: { type: 'string' },
|
||||||
schema_version: { type: 'integer', default: 1 },
|
schema_version: { type: 'integer', default: 1 },
|
||||||
|
|
@ -433,11 +387,7 @@ export async function registerSyncRoutes(
|
||||||
const token = extractBearerToken(request);
|
const token = extractBearerToken(request);
|
||||||
const tenantId = resolveTenantId(request);
|
const tenantId = resolveTenantId(request);
|
||||||
const device = await syncService.authenticateDevice(token, tenantId);
|
const device = await syncService.authenticateDevice(token, tenantId);
|
||||||
const results = await syncService.push(
|
const results = await syncService.push(device.tenant_id, device, request.body?.commands ?? []);
|
||||||
device.tenant_id,
|
|
||||||
device,
|
|
||||||
request.body?.commands ?? [],
|
|
||||||
);
|
|
||||||
return reply.code(200).send({ results });
|
return reply.code(200).send({ results });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleSyncError(error, request);
|
handleSyncError(error, request);
|
||||||
|
|
@ -478,11 +428,7 @@ export async function registerSyncRoutes(
|
||||||
const token = extractBearerToken(request);
|
const token = extractBearerToken(request);
|
||||||
const tenantId = resolveTenantId(request);
|
const tenantId = resolveTenantId(request);
|
||||||
const device = await syncService.authenticateDevice(token, tenantId);
|
const device = await syncService.authenticateDevice(token, tenantId);
|
||||||
const result = await syncService.acknowledge(
|
const result = await syncService.acknowledge(device.tenant_id, device.id, request.body.cursor);
|
||||||
device.tenant_id,
|
|
||||||
device.id,
|
|
||||||
request.body.cursor,
|
|
||||||
);
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleSyncError(error, request);
|
handleSyncError(error, request);
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
// [HARNESS] Позитивный контроль стенда: эти тесты обязаны быть зелёными всегда.
|
|
||||||
// Если они красные — сломан сам стенд, и красные дефектные сценарии ничего не доказывают.
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
||||||
import pg from 'pg';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { api, ctx, describeResponse } from './harness/client.js';
|
|
||||||
|
|
||||||
describe('HARNESS: реальный сервер + PostgreSQL 17', () => {
|
|
||||||
it('[HARNESS] сервер отвечает live/ready (ready проверяет БД)', async () => {
|
|
||||||
const live = await api('GET', '/live');
|
|
||||||
expect(live.status).toBe(200);
|
|
||||||
const ready = await api('GET', '/ready');
|
|
||||||
expect(ready.status, describeResponse(ready)).toBe(200);
|
|
||||||
expect(ready.body).toEqual({ status: 'ready' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[HARNESS] БД: PostgreSQL 17, приложение под ролью без SUPERUSER/BYPASSRLS, мигратор без SUPERUSER', async () => {
|
|
||||||
const client = new pg.Client({ connectionString: ctx().appDatabaseUrl });
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
const version = await client.query<{ server_version_num: string }>('SHOW server_version_num');
|
|
||||||
expect(Number(version.rows[0]!.server_version_num)).toBeGreaterThanOrEqual(170000);
|
|
||||||
const roles = await client.query<{
|
|
||||||
rolname: string;
|
|
||||||
rolsuper: boolean;
|
|
||||||
rolbypassrls: boolean;
|
|
||||||
rolcreaterole: boolean;
|
|
||||||
}>(
|
|
||||||
`SELECT rolname, rolsuper, rolbypassrls, rolcreaterole FROM pg_roles
|
|
||||||
WHERE rolname IN (current_user, 'krona_migrator') ORDER BY rolname`,
|
|
||||||
);
|
|
||||||
expect(roles.rows).toEqual([
|
|
||||||
{ rolname: 'krona_migrator', rolsuper: false, rolbypassrls: false, rolcreaterole: false },
|
|
||||||
{ rolname: 'krona_staff_app', rolsuper: false, rolbypassrls: false, rolcreaterole: false },
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[HARNESS] миграции применены мигратором, app-роль не видит schema_migrations', async () => {
|
|
||||||
const migrator = new pg.Client({ connectionString: ctx().migrationDatabaseUrl });
|
|
||||||
await migrator.connect();
|
|
||||||
try {
|
|
||||||
const applied = await migrator.query<{ count: string }>(
|
|
||||||
'SELECT count(*)::text AS count FROM schema_migrations',
|
|
||||||
);
|
|
||||||
expect(Number(applied.rows[0]!.count)).toBeGreaterThanOrEqual(11);
|
|
||||||
} finally {
|
|
||||||
await migrator.end();
|
|
||||||
}
|
|
||||||
const app = new pg.Client({ connectionString: ctx().appDatabaseUrl });
|
|
||||||
await app.connect();
|
|
||||||
try {
|
|
||||||
await expect(app.query('SELECT 1 FROM schema_migrations')).rejects.toThrow(
|
|
||||||
/permission denied/,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await app.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[HARNESS] токены: без токена 401, случайный токен 401, manager-токен 200', async () => {
|
|
||||||
expect((await api('GET', '/portal')).status).toBe(401);
|
|
||||||
expect((await api('GET', '/portal', { token: 'x'.repeat(64) })).status).toBe(401);
|
|
||||||
const ok = await api('GET', '/portal', { token: ctx().managerToken });
|
|
||||||
expect(ok.status, describeResponse(ok)).toBe(200);
|
|
||||||
expect(ok.body.viewer.role).toBe('manager');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[HARNESS] терминал: отметка сотрудника из импорта принимается (201)', async () => {
|
|
||||||
const { importEmployees, clock, daysAgo } = await import('./harness/fixtures.js');
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const response = await clock(employee!.id, 'check_in', daysAgo(1));
|
|
||||||
expect(response.status, describeResponse(response)).toBe(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [CTRL] Позитивные контроли безопасности, которые аудит признал рабочими: они
|
|
||||||
// должны оставаться зелёными и доказывают, что сценарии умеют быть зелёными.
|
|
||||||
it('[CTRL] RBAC: терминал не видит портал и не создаёт сотрудников; сотрудник видит только себя', async () => {
|
|
||||||
const { importEmployees, issueEmployeeToken } = await import('./harness/fixtures.js');
|
|
||||||
const [self, other] = await importEmployees(2);
|
|
||||||
const employeeToken = await issueEmployeeToken(self!.id);
|
|
||||||
const terminalPortal = await api('GET', '/portal', { token: ctx().terminalToken });
|
|
||||||
const terminalCreate = await api('POST', '/employees', {
|
|
||||||
token: ctx().terminalToken,
|
|
||||||
body: { personnel_number: 'E2E-CTRL', display_name: 'E2E-CTRL' },
|
|
||||||
});
|
|
||||||
const own = await api('GET', '/portal', { token: employeeToken });
|
|
||||||
const rows = (own.body?.employees ?? []) as Array<{ id: string }>;
|
|
||||||
const ids = rows.map((row) => row.id);
|
|
||||||
expect({
|
|
||||||
terminalPortal: terminalPortal.status,
|
|
||||||
terminalCreate: terminalCreate.status,
|
|
||||||
own: own.status,
|
|
||||||
seesSelf: ids.includes(self!.id),
|
|
||||||
seesOther: ids.includes(other!.id),
|
|
||||||
}).toEqual({
|
|
||||||
terminalPortal: 403,
|
|
||||||
terminalCreate: 403,
|
|
||||||
own: 200,
|
|
||||||
seesSelf: true,
|
|
||||||
seesOther: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[CTRL] уволенный сотрудник теряет доступ (401)', async () => {
|
|
||||||
const { importEmployees, issueEmployeeToken } = await import('./harness/fixtures.js');
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const employeeToken = await issueEmployeeToken(employee!.id);
|
|
||||||
expect((await api('GET', '/portal', { token: employeeToken })).status).toBe(200);
|
|
||||||
const dismiss = await api('PATCH', `/employees/${employee!.id}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { status: 'dismissed' },
|
|
||||||
});
|
|
||||||
expect(dismiss.status, describeResponse(dismiss)).toBe(200);
|
|
||||||
expect((await api('GET', '/portal', { token: employeeToken })).status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
// Сценарии дефектов основного API (ID из аудита Grok 2026-09-26; сопоставление с ID
|
|
||||||
// владельца — в REPORT.md). Каждый тест проверяет ПРАВИЛЬНОЕ поведение, поэтому
|
|
||||||
// красный тест = открытый дефект.
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
||||||
import { createECDH, randomBytes } from 'node:crypto';
|
|
||||||
import { createServer, type Server } from 'node:net';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { api, ctx, dbQuery, describeResponse } from './harness/client.js';
|
|
||||||
import { randomUlid, synthetic } from './harness/context.js';
|
|
||||||
import {
|
|
||||||
SYNTHETIC_RATE_MINOR,
|
|
||||||
assignHourly,
|
|
||||||
clock,
|
|
||||||
createOrgUnits,
|
|
||||||
daysAgo,
|
|
||||||
hourlyRuleId,
|
|
||||||
importEmployees,
|
|
||||||
isoDate,
|
|
||||||
issueEmployeeToken,
|
|
||||||
workedShift,
|
|
||||||
} from './harness/fixtures.js';
|
|
||||||
|
|
||||||
function pushKeys() {
|
|
||||||
const ecdh = createECDH('prime256v1');
|
|
||||||
ecdh.generateKeys();
|
|
||||||
return {
|
|
||||||
p256dh: ecdh.getPublicKey().toString('base64url'),
|
|
||||||
auth: randomBytes(16).toString('base64url'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function monthOf(date: Date): string {
|
|
||||||
return date.toISOString().slice(0, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function accrualsFor(employeeId: string) {
|
|
||||||
return dbQuery<{ amount_minor: string; ref_id: string }>(
|
|
||||||
`SELECT amount_minor::text AS amount_minor, ref_id FROM staff_payroll_ledger
|
|
||||||
WHERE employee_id = $1 AND kind = 'accrual'`,
|
|
||||||
[employeeId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Основной API на реальной PostgreSQL', () => {
|
|
||||||
it('[K4/N1] менеджер создаёт сотрудника через POST /employees → 201', async () => {
|
|
||||||
const number = `E2E-${randomUlid().slice(-8)}`;
|
|
||||||
const response = await api('POST', '/employees', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
personnel_number: number,
|
|
||||||
display_name: synthetic('Сотрудник'),
|
|
||||||
position: 'E2E-должность',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(201);
|
|
||||||
const rows = await dbQuery('SELECT id FROM staff_employees WHERE personnel_number = $1', [
|
|
||||||
number,
|
|
||||||
]);
|
|
||||||
expect(rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K3/N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
await assignHourly(employee!.id, unit!.id);
|
|
||||||
const day = daysAgo(3);
|
|
||||||
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
|
|
||||||
|
|
||||||
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { status: 'confirmed' },
|
|
||||||
});
|
|
||||||
expect(confirm.status, `подтверждение смены: ${describeResponse(confirm)}`).toBe(200);
|
|
||||||
|
|
||||||
const calculate = await api('POST', '/payroll/calculate', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { month: monthOf(day), org_unit_id: unit!.id },
|
|
||||||
});
|
|
||||||
expect(calculate.status, `расчёт: ${describeResponse(calculate)}`).toBe(200);
|
|
||||||
const runId = (calculate.body.run_id ?? calculate.body.id ?? calculate.body.run?.id) as string;
|
|
||||||
const approve = await api('POST', `/payroll/runs/${runId}/approve`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
});
|
|
||||||
expect(approve.status, `утверждение: ${describeResponse(approve)}`).toBe(200);
|
|
||||||
|
|
||||||
const accruals = await accrualsFor(employee!.id);
|
|
||||||
const total = accruals.reduce((sum, row) => sum + BigInt(row.amount_minor), 0n);
|
|
||||||
expect(total).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K5/N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
await assignHourly(employee!.id, unit!.id);
|
|
||||||
const day = daysAgo(2);
|
|
||||||
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
|
|
||||||
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { status: 'confirmed' },
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
confirm.status,
|
|
||||||
`подтверждение смены (предусловие, см. N2): ${describeResponse(confirm)}`,
|
|
||||||
).toBe(200);
|
|
||||||
|
|
||||||
const month = monthOf(day);
|
|
||||||
const first = await api('POST', '/payroll/calculate', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { month, org_unit_id: unit!.id },
|
|
||||||
});
|
|
||||||
expect(first.status, `первый расчёт (предусловие, см. N2): ${describeResponse(first)}`).toBe(
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
const firstRun = (first.body.run_id ?? first.body.id) as string;
|
|
||||||
expect(
|
|
||||||
(await api('POST', `/payroll/runs/${firstRun}/approve`, { token: ctx().managerToken }))
|
|
||||||
.status,
|
|
||||||
).toBe(200);
|
|
||||||
|
|
||||||
const lastDay = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth() + 1, 0));
|
|
||||||
const secondFrom = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), 2));
|
|
||||||
const second = await api('POST', '/payroll/calculate', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { from: isoDate(secondFrom), to: isoDate(lastDay), org_unit_id: unit!.id },
|
|
||||||
});
|
|
||||||
if (second.status === 200) {
|
|
||||||
const secondRun = (second.body.run_id ?? second.body.id) as string;
|
|
||||||
await api('POST', `/payroll/runs/${secondRun}/approve`, { token: ctx().managerToken });
|
|
||||||
} else {
|
|
||||||
expect(second.status, describeResponse(second)).toBe(409);
|
|
||||||
}
|
|
||||||
const accruals = await accrualsFor(employee!.id);
|
|
||||||
const total = accruals.reduce((sum, row) => sum + BigInt(row.amount_minor), 0n);
|
|
||||||
expect(total, 'смена должна быть начислена ровно один раз').toBe(
|
|
||||||
BigInt(8 * SYNTHETIC_RATE_MINOR),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K12/N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const employeeToken = await issueEmployeeToken(employee!.id);
|
|
||||||
let connections = 0;
|
|
||||||
const listener: Server = createServer((socket) => {
|
|
||||||
connections += 1;
|
|
||||||
socket.destroy();
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => listener.listen(0, '127.0.0.1', resolve));
|
|
||||||
const address = listener.address();
|
|
||||||
const port = typeof address === 'object' && address ? address.port : 0;
|
|
||||||
try {
|
|
||||||
const subscribe = await api('POST', '/push/subscriptions', {
|
|
||||||
token: employeeToken,
|
|
||||||
body: { endpoint: `https://127.0.0.1:${port}/e2e-internal-admin`, keys: pushKeys() },
|
|
||||||
});
|
|
||||||
// Инициируем отправку push этому сотруднику (отметка терминала) и ждём попытку соединения.
|
|
||||||
await clock(employee!.id, 'check_in', daysAgo(0, 0));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2_500));
|
|
||||||
expect(
|
|
||||||
{ status: subscribe.status, connections },
|
|
||||||
`подписка: ${describeResponse(subscribe)}`,
|
|
||||||
).toEqual({
|
|
||||||
status: expect.toSatisfy((s: number) => s === 400 || s === 422 || s === 403),
|
|
||||||
connections: 0,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
listener.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[N9] чужой push-endpoint нельзя перепривязать к другому сотруднику', async () => {
|
|
||||||
const [employeeA, employeeB] = await importEmployees(2);
|
|
||||||
const tokenA = await issueEmployeeToken(employeeA!.id);
|
|
||||||
const tokenB = await issueEmployeeToken(employeeB!.id);
|
|
||||||
const endpoint = `https://fcm.googleapis.com/fcm/send/e2e-${randomBytes(16).toString('hex')}`;
|
|
||||||
const first = await api('POST', '/push/subscriptions', {
|
|
||||||
token: tokenA,
|
|
||||||
body: { endpoint, keys: pushKeys() },
|
|
||||||
});
|
|
||||||
expect(first.status, describeResponse(first)).toBe(201);
|
|
||||||
const second = await api('POST', '/push/subscriptions', {
|
|
||||||
token: tokenB,
|
|
||||||
body: { endpoint, keys: pushKeys() },
|
|
||||||
});
|
|
||||||
const owner = await dbQuery<{ employee_id: string }>(
|
|
||||||
'SELECT employee_id FROM staff_push_subscriptions WHERE endpoint = $1',
|
|
||||||
[endpoint],
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
{ status: second.status, owner: owner.map((row) => row.employee_id) },
|
|
||||||
describeResponse(second),
|
|
||||||
).toEqual({
|
|
||||||
status: expect.toSatisfy((s: number) => s === 409 || s === 403),
|
|
||||||
owner: [employeeA!.id],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[N12] PUT pay-assignment отвергает лишние поля и нереальные суммы (400)', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const payRuleId = await hourlyRuleId();
|
|
||||||
const extra = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { pay_rule_id: payRuleId, rate_minor: SYNTHETIC_RATE_MINOR, e2e_unexpected_field: 'x' },
|
|
||||||
});
|
|
||||||
const huge = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
pay_rule_id: payRuleId,
|
|
||||||
rate_minor: Number.MAX_SAFE_INTEGER,
|
|
||||||
guarantee_minor: Number.MAX_SAFE_INTEGER,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
{ extra: extra.status, huge: huge.status },
|
|
||||||
`${describeResponse(extra)} | ${describeResponse(huge)}`,
|
|
||||||
).toEqual({
|
|
||||||
extra: 400,
|
|
||||||
huge: 400,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[N10] терминальный токен не читает справочники профессий и точек (403)', async () => {
|
|
||||||
const professions = await api('GET', '/professions', { token: ctx().terminalToken });
|
|
||||||
const units = await api('GET', '/org-units', { token: ctx().terminalToken });
|
|
||||||
expect({ professions: professions.status, units: units.status }).toEqual({
|
|
||||||
professions: 403,
|
|
||||||
units: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K1/F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401)', async () => {
|
|
||||||
const portal = await api('GET', '/portal', { base: 'open' });
|
|
||||||
const payroll = await api('GET', '/payroll', { base: 'open' });
|
|
||||||
const write = await api('POST', '/professions', {
|
|
||||||
base: 'open',
|
|
||||||
body: { name: synthetic('Профессия') },
|
|
||||||
});
|
|
||||||
expect({ portal: portal.status, payroll: payroll.status, write: write.status }).toEqual({
|
|
||||||
portal: 401,
|
|
||||||
payroll: 401,
|
|
||||||
write: 401,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K10/F3] cookie сессии не содержит сырой токен и отзывается при выходе', async () => {
|
|
||||||
const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } });
|
|
||||||
expect(login.status, describeResponse(login)).toBe(200);
|
|
||||||
const setCookie = login.headers.get('set-cookie') ?? '';
|
|
||||||
const value = decodeURIComponent(
|
|
||||||
/__Host-krona_staff_session=([^;]*)/.exec(setCookie)?.[1] ?? '',
|
|
||||||
);
|
|
||||||
const cookie = `__Host-krona_staff_session=${encodeURIComponent(value)}`;
|
|
||||||
const logout = await api('DELETE', '/session', { headers: { cookie } });
|
|
||||||
expect(logout.status).toBe(204);
|
|
||||||
const reuse = await api('GET', '/portal', { headers: { cookie } });
|
|
||||||
expect({ rawTokenInCookie: value === ctx().managerToken, afterLogout: reuse.status }).toEqual({
|
|
||||||
rawTokenInCookie: false,
|
|
||||||
afterLogout: 401,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[F10] мутация по cookie без Origin отклоняется (CSRF, 403)', async () => {
|
|
||||||
const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } });
|
|
||||||
const setCookie = login.headers.get('set-cookie') ?? '';
|
|
||||||
const cookie = /(__Host-krona_staff_session=[^;]*)/.exec(setCookie)?.[1] ?? '';
|
|
||||||
const response = await api('POST', '/professions', {
|
|
||||||
headers: { cookie },
|
|
||||||
body: { name: synthetic('CSRF-профессия') },
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[F7] повтор Idempotency-Key с другим телом не создаёт второе событие', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const key = `e2e-${randomUlid()}`;
|
|
||||||
const send = (sourceEventId: string) =>
|
|
||||||
api('POST', '/attendance/events', {
|
|
||||||
token: ctx().terminalToken,
|
|
||||||
headers: { 'idempotency-key': key },
|
|
||||||
body: {
|
|
||||||
employee_id: employee!.id,
|
|
||||||
event_type: 'check_in',
|
|
||||||
occurred_at: daysAgo(1).toISOString(),
|
|
||||||
source_event_id: sourceEventId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const first = await send(`e2e-${randomUlid()}`);
|
|
||||||
const second = await send(`e2e-${randomUlid()}`);
|
|
||||||
expect({ first: first.status, secondCreated: second.status === 201 }).toEqual({
|
|
||||||
first: 201,
|
|
||||||
secondCreated: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[N11] импорт с неверным kind начисления → 400, а не 500', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const response = await api('POST', '/imports/owner-workbook', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
source_name: synthetic('import-bad-kind'),
|
|
||||||
payroll_entries: [
|
|
||||||
{
|
|
||||||
external_key: `e2e-${randomUlid()}`,
|
|
||||||
employee_external_ref: employee!.external_ref,
|
|
||||||
business_date: isoDate(daysAgo(1)),
|
|
||||||
kind: 'e2e_bogus_kind',
|
|
||||||
amount_minor: 100,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K11/F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => {
|
|
||||||
const statuses: number[] = [];
|
|
||||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
||||||
const response = await api('POST', '/session', {
|
|
||||||
base: 'ratelimit',
|
|
||||||
body: { access_token: randomBytes(32).toString('hex') },
|
|
||||||
});
|
|
||||||
statuses.push(response.status);
|
|
||||||
if (response.status === 429) break;
|
|
||||||
}
|
|
||||||
expect(statuses.includes(429), `статусы: ${[...new Set(statuses)].join(',')}`).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
// Сценарии офлайн-синхронизации (/api/v1/staff/sync/*, миграция 0011).
|
|
||||||
// Проверяют ПРАВИЛЬНОЕ поведение: красный тест = открытый дефект.
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { api, ctx, dbQuery, describeResponse } from './harness/client.js';
|
|
||||||
import { randomUlid, synthetic } from './harness/context.js';
|
|
||||||
import { createOrgUnits, daysAgo, importEmployees, isoDate } from './harness/fixtures.js';
|
|
||||||
|
|
||||||
interface Device {
|
|
||||||
id: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerDevice(orgUnitId?: string): Promise<Device> {
|
|
||||||
const response = await api('POST', '/sync/devices', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
device_code: `E2E-${randomUlid().slice(-10)}`,
|
|
||||||
name: synthetic('Терминал'),
|
|
||||||
...(orgUnitId ? { org_unit_id: orgUnitId } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(201);
|
|
||||||
return { id: response.body.device.id as string, token: response.body.token as string };
|
|
||||||
}
|
|
||||||
|
|
||||||
function command(commandType: string, payload: Record<string, unknown>) {
|
|
||||||
return {
|
|
||||||
command_type: commandType,
|
|
||||||
schema_version: 1,
|
|
||||||
idempotency_key: `e2e-${randomUlid()}`,
|
|
||||||
correlation_id: randomUlid(),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
payload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function push(device: Device, commands: unknown[]) {
|
|
||||||
return api('POST', '/sync/push', { token: device.token, body: { commands } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pullAll(device: Device, cursor: string | null = null) {
|
|
||||||
const changes: Array<{ entity_type: string; entity_id: string }> = [];
|
|
||||||
let next: string | null = cursor;
|
|
||||||
for (let page = 0; page < 50; page += 1) {
|
|
||||||
const response = await api('POST', '/sync/pull', {
|
|
||||||
token: device.token,
|
|
||||||
body: { cursor: next, limit: 100 },
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(200);
|
|
||||||
changes.push(...(response.body.changes as Array<{ entity_type: string; entity_id: string }>));
|
|
||||||
next = response.body.next_cursor as string;
|
|
||||||
if (!response.body.has_more) break;
|
|
||||||
}
|
|
||||||
return { changes, cursor: next };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Офлайн-синхронизация на реальной PostgreSQL', () => {
|
|
||||||
it('[K13/SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется)', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const device = await registerDevice(unit!.id);
|
|
||||||
const sessionId = randomUlid();
|
|
||||||
const response = await push(device, [
|
|
||||||
command('upsert_work_session', {
|
|
||||||
id: sessionId,
|
|
||||||
employee_id: employee!.id,
|
|
||||||
org_unit_id: unit!.id,
|
|
||||||
business_date: isoDate(daysAgo(1)),
|
|
||||||
worked_minutes: 600,
|
|
||||||
status: 'confirmed',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const stored = await dbQuery<{ status: string }>(
|
|
||||||
'SELECT status FROM staff_work_sessions WHERE id = $1',
|
|
||||||
[sessionId],
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
{
|
|
||||||
http: response.status,
|
|
||||||
result: response.body?.results?.[0]?.status,
|
|
||||||
stored: stored[0]?.status ?? null,
|
|
||||||
},
|
|
||||||
describeResponse(response),
|
|
||||||
).toEqual({ http: 200, result: 'rejected', stored: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K13/SYNC-B] устройство не может утвердить табель (status=approved отклоняется)', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const device = await registerDevice();
|
|
||||||
const entryId = randomUlid();
|
|
||||||
const response = await push(device, [
|
|
||||||
command('upsert_timesheet_entry', {
|
|
||||||
id: entryId,
|
|
||||||
employee_id: employee!.id,
|
|
||||||
work_date: isoDate(daysAgo(1)),
|
|
||||||
minutes_worked: 600,
|
|
||||||
status: 'approved',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const stored = await dbQuery<{ status: string }>(
|
|
||||||
'SELECT status FROM staff_timesheet_entries WHERE id = $1',
|
|
||||||
[entryId],
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
{ result: response.body?.results?.[0]?.status, stored: stored[0]?.status ?? null },
|
|
||||||
describeResponse(response),
|
|
||||||
).toEqual({ result: 'rejected', stored: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[SYNC-C0] начальная загрузка (pull без курсора) работает на реальной БД (200)', async () => {
|
|
||||||
await createOrgUnits(1);
|
|
||||||
await importEmployees(1);
|
|
||||||
const device = await registerDevice();
|
|
||||||
const response = await api('POST', '/sync/pull', {
|
|
||||||
token: device.token,
|
|
||||||
body: { cursor: null },
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(200);
|
|
||||||
const types = new Set(
|
|
||||||
(response.body.changes as Array<{ entity_type: string }>).map((c) => c.entity_type),
|
|
||||||
);
|
|
||||||
expect(types.has('employee') && types.has('org_unit')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[S5/SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично', async () => {
|
|
||||||
const employees = await importEmployees(120);
|
|
||||||
const device = await registerDevice();
|
|
||||||
const { changes } = await pullAll(device, null);
|
|
||||||
const received = new Set(
|
|
||||||
changes.filter((c) => c.entity_type === 'employee').map((c) => c.entity_id),
|
|
||||||
);
|
|
||||||
const missing = employees.filter((employee) => !received.has(employee.id)).length;
|
|
||||||
expect(missing, `не доставлено сотрудников: ${missing} из ${employees.length}`).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[S5/SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull', async () => {
|
|
||||||
const device = await registerDevice();
|
|
||||||
// Курсор берём из /sync/status, чтобы сценарий не зависел от начальной загрузки (SYNC-C0).
|
|
||||||
const status = await api('GET', '/sync/status', { token: device.token });
|
|
||||||
expect(status.status, describeResponse(status)).toBe(200);
|
|
||||||
const cursor = status.body.latest_cursor as string;
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const patch = await api('PATCH', `/employees/${employee!.id}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { display_name: synthetic('Переименован') },
|
|
||||||
});
|
|
||||||
expect(patch.status, describeResponse(patch)).toBe(200);
|
|
||||||
const { changes } = await pullAll(device, cursor);
|
|
||||||
expect(changes.some((c) => c.entity_type === 'employee' && c.entity_id === employee!.id)).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[S4/SYNC-E] отметка уволенного сотрудника через sync отклоняется', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const dismiss = await api('PATCH', `/employees/${employee!.id}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { status: 'dismissed' },
|
|
||||||
});
|
|
||||||
expect(dismiss.status, describeResponse(dismiss)).toBe(200);
|
|
||||||
const device = await registerDevice();
|
|
||||||
const response = await push(device, [
|
|
||||||
command('attendance_event', {
|
|
||||||
employee_id: employee!.id,
|
|
||||||
event_type: 'check_in',
|
|
||||||
occurred_at: daysAgo(0, 0).toISOString(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(response.body?.results?.[0]?.status, describeResponse(response)).toBe('rejected');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[S4/SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется', async () => {
|
|
||||||
const [unitA, unitB] = await createOrgUnits(2);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const device = await registerDevice(unitA!.id);
|
|
||||||
const own = command('attendance_event', {
|
|
||||||
employee_id: employee!.id,
|
|
||||||
event_type: 'check_in',
|
|
||||||
occurred_at: daysAgo(0, 0).toISOString(),
|
|
||||||
});
|
|
||||||
const foreign = command('attendance_event', {
|
|
||||||
employee_id: employee!.id,
|
|
||||||
event_type: 'check_out',
|
|
||||||
occurred_at: new Date(daysAgo(0, 0).getTime() + 60_000).toISOString(),
|
|
||||||
org_unit_id: unitB!.id,
|
|
||||||
});
|
|
||||||
const response = await push(device, [own, foreign]);
|
|
||||||
expect(response.status, describeResponse(response)).toBe(200);
|
|
||||||
const ownEventId = response.body.results[0]?.result?.event_id as string | undefined;
|
|
||||||
const stored = await dbQuery<{ org_unit_id: string | null }>(
|
|
||||||
'SELECT org_unit_id FROM staff_attendance_events WHERE id = $1',
|
|
||||||
[ownEventId ?? ''],
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
{
|
|
||||||
ownOrgUnit: stored[0]?.org_unit_id ?? null,
|
|
||||||
foreign: response.body.results[1]?.status,
|
|
||||||
},
|
|
||||||
describeResponse(response),
|
|
||||||
).toEqual({ ownOrgUnit: unitA!.id, foreign: 'rejected' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[S3/SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500)', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const device = await registerDevice();
|
|
||||||
const good = command('attendance_event', {
|
|
||||||
employee_id: employee!.id,
|
|
||||||
event_type: 'check_in',
|
|
||||||
occurred_at: daysAgo(0, 0).toISOString(),
|
|
||||||
});
|
|
||||||
const bad = command('attendance_event', {
|
|
||||||
employee_id: employee!.id,
|
|
||||||
event_type: 'check_out',
|
|
||||||
occurred_at: 'e2e-not-a-timestamp',
|
|
||||||
});
|
|
||||||
const response = await push(device, [good, bad]);
|
|
||||||
expect(
|
|
||||||
{
|
|
||||||
http: response.status,
|
|
||||||
statuses: (response.body?.results ?? []).map((r: { status: string }) => r.status),
|
|
||||||
},
|
|
||||||
describeResponse(response),
|
|
||||||
).toEqual({ http: 200, statuses: ['applied', 'rejected'] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[S8/SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403)', async () => {
|
|
||||||
const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } });
|
|
||||||
const cookie =
|
|
||||||
/(__Host-krona_staff_session=[^;]*)/.exec(login.headers.get('set-cookie') ?? '')?.[1] ?? '';
|
|
||||||
const response = await api('POST', '/sync/devices', {
|
|
||||||
headers: { cookie, origin: 'https://attacker.e2e.invalid' },
|
|
||||||
body: { device_code: `E2E-${randomUlid().slice(-10)}`, name: synthetic('CSRF-терминал') },
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K1/SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401)', async () => {
|
|
||||||
const response = await api('POST', '/sync/devices', {
|
|
||||||
base: 'open',
|
|
||||||
body: {
|
|
||||||
device_code: `E2E-${randomUlid().slice(-10)}`,
|
|
||||||
name: synthetic('Анонимный терминал'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[S2/SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const device = await registerDevice();
|
|
||||||
const response = await push(device, [
|
|
||||||
command('attendance_event', {
|
|
||||||
employee_id: employee!.id,
|
|
||||||
event_type: 'check_in',
|
|
||||||
occurred_at: '2001-01-01T09:00:00.000Z',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(response.body?.results?.[0]?.status, describeResponse(response)).toBe('rejected');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[SYNC-K] ack курсора из будущего (больше выданного сервером) отклоняется (400)', async () => {
|
|
||||||
const device = await registerDevice();
|
|
||||||
const tenant = ctx().tenantId;
|
|
||||||
const seq = '999999999';
|
|
||||||
const hash = createHash('sha256')
|
|
||||||
.update(`${tenant}:${seq}:krona_staff_sync_cursor_salt_v1`)
|
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 12);
|
|
||||||
const response = await api('POST', '/sync/ack', {
|
|
||||||
token: device.token,
|
|
||||||
body: { cursor: `cur_${seq}_${hash}` },
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[SYNC-L] отозванное устройство не может синхронизироваться (403) — позитивный контроль', async () => {
|
|
||||||
const device = await registerDevice();
|
|
||||||
const revoke = await api('POST', `/sync/devices/${device.id}/revoke`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
});
|
|
||||||
expect(revoke.status, describeResponse(revoke)).toBe(200);
|
|
||||||
const status = await api('GET', '/sync/status', { token: device.token });
|
|
||||||
expect(status.status).toBe(403);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
// Сценарии решений владельца (ожидаемо КРАСНЫЕ до этапа 4 Codex).
|
|
||||||
// Публичного API для ролей точки/бухгалтера/директора, пакетов зарплаты и согласия
|
|
||||||
// на биометрию пока нет, поэтому тесты написаны против наиболее вероятного контракта,
|
|
||||||
// описанного в tests/e2e/NOTES-FOR-EXECUTORS.md (C1–C3). Если Codex выберет другой
|
|
||||||
// контракт — меняется только этот файл и NOTES, смысл проверок остаётся.
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { api, ctx, dbQuery, describeResponse } from './harness/client.js';
|
|
||||||
import { synthetic } from './harness/context.js';
|
|
||||||
import {
|
|
||||||
assignHourly,
|
|
||||||
createOrgUnits,
|
|
||||||
daysAgo,
|
|
||||||
importEmployees,
|
|
||||||
isoDate,
|
|
||||||
workedShift,
|
|
||||||
type OrgUnit,
|
|
||||||
} from './harness/fixtures.js';
|
|
||||||
|
|
||||||
type Role = 'point_manager' | 'accountant' | 'director';
|
|
||||||
|
|
||||||
/** C1: главный менеджер выдаёт ключ роли (standalone-аналог Core RBAC). */
|
|
||||||
async function grant(role: Role, orgUnitIds: string[] = []): Promise<string> {
|
|
||||||
const response = await api('POST', '/access-grants', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { role, org_unit_ids: orgUnitIds, display_name: synthetic(role) },
|
|
||||||
});
|
|
||||||
expect(response.status, `C1 POST /access-grants (${role}): ${describeResponse(response)}`).toBe(
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
return response.body.access_token as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirm(sessionId: string) {
|
|
||||||
const response = await api('PATCH', `/work-sessions/${sessionId}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { status: 'confirmed' },
|
|
||||||
});
|
|
||||||
expect(response.status, `подтверждение смены (см. N2): ${describeResponse(response)}`).toBe(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface World {
|
|
||||||
unitA: OrgUnit;
|
|
||||||
unitB: OrgUnit;
|
|
||||||
employeeId: string;
|
|
||||||
sessionId: string;
|
|
||||||
day: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Точка A и B, сотрудник точки A со сменой в точке A (по умолчанию подтверждённой; см. N2). */
|
|
||||||
async function world(confirmShift = true): Promise<World> {
|
|
||||||
const [unitA, unitB] = await createOrgUnits(2);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
await assignHourly(employee!.id, unitA!.id);
|
|
||||||
const day = daysAgo(4);
|
|
||||||
const sessionId = await workedShift(employee!.id, unitA!.id, day, 8);
|
|
||||||
if (confirmShift) await confirm(sessionId);
|
|
||||||
return { unitA: unitA!, unitB: unitB!, employeeId: employee!.id, sessionId, day };
|
|
||||||
}
|
|
||||||
|
|
||||||
function period(day: Date) {
|
|
||||||
const from = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), 1));
|
|
||||||
const to = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth() + 1, 0));
|
|
||||||
return { from: isoDate(from), to: isoDate(to) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** C2: пакет зарплаты точки за месяц. */
|
|
||||||
async function createBatch(token: string, orgUnitId: string, day: Date) {
|
|
||||||
return api('POST', '/payroll/batches', {
|
|
||||||
token,
|
|
||||||
body: { org_unit_id: orgUnitId, ...period(day) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submittedBatch(w: World, accountant: string): Promise<string> {
|
|
||||||
const created = await createBatch(accountant, w.unitA.id, w.day);
|
|
||||||
expect(created.status, `C2 создание пакета: ${describeResponse(created)}`).toBe(201);
|
|
||||||
const batchId = created.body.id as string;
|
|
||||||
const submit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant });
|
|
||||||
expect(submit.status, `C2 отправка пакета: ${describeResponse(submit)}`).toBe(200);
|
|
||||||
return batchId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ledgerFor(employeeId: string) {
|
|
||||||
return dbQuery<{ kind: string; amount_minor: string }>(
|
|
||||||
`SELECT kind, amount_minor::text AS amount_minor FROM staff_payroll_ledger WHERE employee_id = $1`,
|
|
||||||
[employeeId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => {
|
|
||||||
it('[D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => {
|
|
||||||
const w = await world(false);
|
|
||||||
const managerA = await grant('point_manager', [w.unitA.id]);
|
|
||||||
const batch = await createBatch(managerA, w.unitB.id, w.day);
|
|
||||||
const legacy = await api('POST', '/payroll/calculate', {
|
|
||||||
token: managerA,
|
|
||||||
body: { month: isoDate(w.day).slice(0, 7), org_unit_id: w.unitB.id },
|
|
||||||
});
|
|
||||||
expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[D6/OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => {
|
|
||||||
const w = await world(false);
|
|
||||||
const managerA = await grant('point_manager', [w.unitA.id]);
|
|
||||||
const created = await createBatch(managerA, w.unitA.id, w.day);
|
|
||||||
expect(
|
|
||||||
created.status,
|
|
||||||
`C2 создание пакета менеджером своей точки: ${describeResponse(created)}`,
|
|
||||||
).toBe(201);
|
|
||||||
const submit = await api('POST', `/payroll/batches/${created.body.id}/submit`, {
|
|
||||||
token: managerA,
|
|
||||||
});
|
|
||||||
expect(submit.status, describeResponse(submit)).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[OWN-03] бухгалтер отправляет пакет → успех, каждый директор получает ровно одно уведомление', async () => {
|
|
||||||
const accountant = await grant('accountant');
|
|
||||||
const directors = [await grant('director'), await grant('director')];
|
|
||||||
const w = await world();
|
|
||||||
const batchId = await submittedBatch(w, accountant);
|
|
||||||
for (const director of directors) {
|
|
||||||
const inbox = await api('GET', '/notifications', { token: director });
|
|
||||||
expect(inbox.status, `C2 уведомления директора: ${describeResponse(inbox)}`).toBe(200);
|
|
||||||
const aboutBatch = (
|
|
||||||
inbox.body.notifications as Array<{ type: string; subject_id: string }>
|
|
||||||
).filter((n) => n.type === 'payroll.batch.submitted' && n.subject_id === batchId);
|
|
||||||
expect(aboutBatch).toHaveLength(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[OWN-04] бухгалтер пытается подписать пакет → 403', async () => {
|
|
||||||
const accountant = await grant('accountant');
|
|
||||||
const w = await world();
|
|
||||||
const batchId = await submittedBatch(w, accountant);
|
|
||||||
const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: accountant });
|
|
||||||
expect(sign.status, describeResponse(sign)).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[OWN-05] до подписи директора в ledger нет начислений и выплат', async () => {
|
|
||||||
const accountant = await grant('accountant');
|
|
||||||
const w = await world();
|
|
||||||
await submittedBatch(w, accountant);
|
|
||||||
expect(await ledgerFor(w.employeeId)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[OWN-06] подпись директора → начисления и выплаты в ledger, пакет и смены неизменяемы', async () => {
|
|
||||||
const accountant = await grant('accountant');
|
|
||||||
const director = await grant('director');
|
|
||||||
const w = await world();
|
|
||||||
const batchId = await submittedBatch(w, accountant);
|
|
||||||
const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director });
|
|
||||||
expect(sign.status, describeResponse(sign)).toBe(200);
|
|
||||||
const kinds = new Set((await ledgerFor(w.employeeId)).map((row) => row.kind));
|
|
||||||
const editShift = await api('PATCH', `/work-sessions/${w.sessionId}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
status: 'confirmed',
|
|
||||||
worked_minutes: 1,
|
|
||||||
correction_note: 'E2E попытка правки после подписи',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const resubmit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant });
|
|
||||||
expect({
|
|
||||||
accrual: kinds.has('accrual'),
|
|
||||||
payout: kinds.has('payout'),
|
|
||||||
editShift: editShift.status,
|
|
||||||
resubmit: resubmit.status,
|
|
||||||
}).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K19/OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => {
|
|
||||||
const accountant = await grant('accountant');
|
|
||||||
const director = await grant('director');
|
|
||||||
const w = await world();
|
|
||||||
const batchId = await submittedBatch(w, accountant);
|
|
||||||
const edit = await api('PATCH', `/work-sessions/${w.sessionId}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
status: 'confirmed',
|
|
||||||
worked_minutes: 420,
|
|
||||||
correction_note: 'E2E правка после отправки',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(edit.status, describeResponse(edit)).toBe(200);
|
|
||||||
const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director });
|
|
||||||
const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant });
|
|
||||||
expect({ sign: sign.status, status: batch.body?.status }).toEqual({
|
|
||||||
sign: 409,
|
|
||||||
status: 'returned',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[OWN-08] возврат с комментарием → пакет у бухгалтера, комментарий сохранён', async () => {
|
|
||||||
const accountant = await grant('accountant');
|
|
||||||
const director = await grant('director');
|
|
||||||
const w = await world();
|
|
||||||
const batchId = await submittedBatch(w, accountant);
|
|
||||||
const comment = synthetic('Комментарий директора');
|
|
||||||
const back = await api('POST', `/payroll/batches/${batchId}/return`, {
|
|
||||||
token: director,
|
|
||||||
body: { comment },
|
|
||||||
});
|
|
||||||
expect(back.status, describeResponse(back)).toBe(200);
|
|
||||||
const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant });
|
|
||||||
expect({ status: batch.body?.status, comment: batch.body?.return_comment }).toEqual({
|
|
||||||
status: 'returned',
|
|
||||||
comment,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[OWN-09] сотрудник точки A отработал смену в точке B → смена попадает в зарплату точки B', async () => {
|
|
||||||
const accountant = await grant('accountant');
|
|
||||||
const [unitA, unitB] = await createOrgUnits(2);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
await assignHourly(employee!.id, unitA!.id);
|
|
||||||
const day = daysAgo(5);
|
|
||||||
const sessionId = await workedShift(employee!.id, unitB!.id, day, 6);
|
|
||||||
await confirm(sessionId);
|
|
||||||
const batchB = await createBatch(accountant, unitB!.id, day);
|
|
||||||
const batchA = await createBatch(accountant, unitA!.id, day);
|
|
||||||
expect(batchB.status, describeResponse(batchB)).toBe(201);
|
|
||||||
const sessionsOf = (batch: typeof batchB) =>
|
|
||||||
((batch.body?.lines ?? []) as Array<{ work_session_id: string }>).map(
|
|
||||||
(l) => l.work_session_id,
|
|
||||||
);
|
|
||||||
expect({
|
|
||||||
inB: sessionsOf(batchB).includes(sessionId),
|
|
||||||
inA: sessionsOf(batchA).includes(sessionId),
|
|
||||||
}).toEqual({
|
|
||||||
inB: true,
|
|
||||||
inA: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K17/OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, {
|
|
||||||
token: ctx().terminalToken,
|
|
||||||
});
|
|
||||||
expect(response.status, `C3: ${describeResponse(response)}`).toBe(200);
|
|
||||||
expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K17/OWN-11] согласие отозвано → статус для терминала меняется', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const setConsent = (status: 'granted' | 'revoked') =>
|
|
||||||
api('PUT', `/employees/${employee!.id}/biometric-consent`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { status, document_ref: synthetic('согласие') },
|
|
||||||
});
|
|
||||||
const enrollment = () =>
|
|
||||||
api('GET', `/terminal/employees/${employee!.id}/enrollment`, { token: ctx().terminalToken });
|
|
||||||
const granted = await setConsent('granted');
|
|
||||||
expect(granted.status, `C3 PUT biometric-consent: ${describeResponse(granted)}`).toBe(200);
|
|
||||||
const before = await enrollment();
|
|
||||||
const revoked = await setConsent('revoked');
|
|
||||||
expect(revoked.status, describeResponse(revoked)).toBe(200);
|
|
||||||
const after = await enrollment();
|
|
||||||
expect({
|
|
||||||
before: before.body?.registration_allowed,
|
|
||||||
after: after.body?.registration_allowed,
|
|
||||||
reason: after.body?.reason,
|
|
||||||
}).toEqual({
|
|
||||||
before: true,
|
|
||||||
after: false,
|
|
||||||
reason: 'consent_revoked',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
// Статическая проверка deploy/pilot/compose.yaml (аналог tests/unit/deployment.test.ts
|
|
||||||
// для корневого compose.yaml). Лежит в tests/e2e, а не в tests/unit: у исполнителя
|
|
||||||
// теста нет права менять tests/unit, а красные пункты (открытые дефекты deploy/)
|
|
||||||
// должны идти через known-defects.json, не ломая `npm run check`.
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { parse } from 'yaml';
|
|
||||||
|
|
||||||
const composePath = path.resolve(import.meta.dirname, '../../deploy/pilot/compose.yaml');
|
|
||||||
const raw = readFileSync(composePath, 'utf8');
|
|
||||||
const compose = parse(raw) as {
|
|
||||||
services: Record<string, { environment?: Record<string, string | number> | string[] }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function environmentOf(service: string): Record<string, string> {
|
|
||||||
const env = compose.services[service]?.environment ?? {};
|
|
||||||
if (Array.isArray(env)) {
|
|
||||||
return Object.fromEntries(
|
|
||||||
env.map((entry) => {
|
|
||||||
const index = entry.indexOf('=');
|
|
||||||
return index < 0 ? [entry, ''] : [entry.slice(0, index), entry.slice(index + 1)];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, String(value)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('deploy/pilot/compose.yaml', () => {
|
|
||||||
it('[K1/PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение}', () => {
|
|
||||||
const interpolations = [...raw.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1]!);
|
|
||||||
expect(interpolations.length).toBeGreaterThan(0);
|
|
||||||
const optional = interpolations.filter((inner) => !/^[A-Z0-9_]+:\?.+$/.test(inner));
|
|
||||||
expect(optional, 'переменные без :? — пустое значение молча попадёт в контейнер').toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K2/PILOT-2] ни один сервис не запускается с NODE_ENV=development', () => {
|
|
||||||
const offenders = Object.keys(compose.services).filter(
|
|
||||||
(service) => environmentOf(service).NODE_ENV === 'development',
|
|
||||||
);
|
|
||||||
expect(offenders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[PILOT-3] у сервиса app нет учётных данных мигратора', () => {
|
|
||||||
const env = environmentOf('app');
|
|
||||||
const keys = Object.keys(env).filter((key) =>
|
|
||||||
['DATABASE_MIGRATION_URL', 'POSTGRES_MIGRATION_PASSWORD', 'POSTGRES_MIGRATION_USER'].includes(
|
|
||||||
key,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const values = Object.entries(env)
|
|
||||||
.filter(([, value]) => /POSTGRES_MIGRATION_PASSWORD|krona_migrator/.test(value))
|
|
||||||
.map(([key]) => key);
|
|
||||||
expect({ keys, values }).toEqual({ keys: [], values: [] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
// Недостающие сценарии из аудита владельца (TASK-GROK-2 §3): денежная логика K6–K9,
|
|
||||||
// удаление точки с устройством (S9), утечка ошибок PostgreSQL (A4), дата расчёта (N12-DATE).
|
|
||||||
// Каждый тест проверяет ПРАВИЛЬНОЕ поведение: красный = открытый дефект. Где путь
|
|
||||||
// упирается в K3 (outbox без occurred_at), assert предусловия прямо называет K3.
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
||||||
import pg from 'pg';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { api, ctx, dbQuery, describeResponse, type ApiResponse } from './harness/client.js';
|
|
||||||
import { randomUlid, synthetic } from './harness/context.js';
|
|
||||||
import {
|
|
||||||
SYNTHETIC_RATE_MINOR,
|
|
||||||
assignHourly,
|
|
||||||
createOrgUnits,
|
|
||||||
daysAgo,
|
|
||||||
importEmployees,
|
|
||||||
isoDate,
|
|
||||||
workedShift,
|
|
||||||
} from './harness/fixtures.js';
|
|
||||||
|
|
||||||
const monthOf = (date: Date) => date.toISOString().slice(0, 7);
|
|
||||||
|
|
||||||
async function ledgerTotal(employeeId: string, kinds = ['accrual', 'adjustment']) {
|
|
||||||
const rows = await dbQuery<{ total: string }>(
|
|
||||||
`SELECT COALESCE(SUM(amount_minor), 0)::text AS total FROM staff_payroll_ledger
|
|
||||||
WHERE employee_id = $1 AND kind = ANY($2::text[])`,
|
|
||||||
[employeeId, kinds],
|
|
||||||
);
|
|
||||||
return BigInt(rows[0]!.total);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmShift(sessionId: string) {
|
|
||||||
const confirm = await api('PATCH', `/work-sessions/${sessionId}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { status: 'confirmed' },
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
confirm.status,
|
|
||||||
`подтверждение смены (предусловие; падает, пока открыт K3): ${describeResponse(confirm)}`,
|
|
||||||
).toBe(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Расчёт и утверждение (подпись) ведомости точки за месяц дня `day`. */
|
|
||||||
async function calculateAndApprove(unitId: string, day: Date): Promise<string> {
|
|
||||||
const calculate = await api('POST', '/payroll/calculate', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { month: monthOf(day), org_unit_id: unitId },
|
|
||||||
});
|
|
||||||
expect(calculate.status, `расчёт (предусловие, K3): ${describeResponse(calculate)}`).toBe(200);
|
|
||||||
const runId = (calculate.body.run_id ?? calculate.body.id) as string;
|
|
||||||
const approve = await api('POST', `/payroll/runs/${runId}/approve`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
});
|
|
||||||
expect(approve.status, `утверждение (предусловие, K3): ${describeResponse(approve)}`).toBe(200);
|
|
||||||
return runId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function payRuleId(code: string): Promise<string> {
|
|
||||||
const response = await api('GET', '/pay-rules', { token: ctx().managerToken });
|
|
||||||
expect(response.status, describeResponse(response)).toBe(200);
|
|
||||||
const rule = (response.body.pay_rules as Array<{ id: string; code: string }>).find(
|
|
||||||
(item) => item.code === code,
|
|
||||||
);
|
|
||||||
expect(rule, `правило ${code} должно быть в пресете`).toBeTruthy();
|
|
||||||
return rule!.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Признаки ошибки PostgreSQL в теле ответа клиенту (SQLSTATE, имена отношений/столбцов). */
|
|
||||||
function postgresLeak(response: ApiResponse): string[] {
|
|
||||||
const leaks: string[] = [];
|
|
||||||
const body = (response.body ?? {}) as Record<string, unknown>;
|
|
||||||
if (typeof body.code === 'string' && /^[0-9A-Z]{5}$/.test(body.code)) {
|
|
||||||
leaks.push(`code=${body.code}`);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
/relation "|column "|violates|constraint|syntax for type|deduced for parameter/i.test(
|
|
||||||
response.text,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
leaks.push(`text=${response.text.slice(0, 120)}`);
|
|
||||||
}
|
|
||||||
return leaks;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Деньги, S9, A4 и даты на реальной PostgreSQL', () => {
|
|
||||||
it('[K6/D5] отклонение смены из подписанной ведомости → 409 или сторно (начисление не остаётся)', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
await assignHourly(employee!.id, unit!.id);
|
|
||||||
const day = daysAgo(4);
|
|
||||||
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
|
|
||||||
await confirmShift(sessionId);
|
|
||||||
await calculateAndApprove(unit!.id, day);
|
|
||||||
expect(await ledgerTotal(employee!.id)).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR));
|
|
||||||
|
|
||||||
const reject = await api('PATCH', `/work-sessions/${sessionId}`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { status: 'rejected', correction_note: synthetic('отказ после подписи') },
|
|
||||||
});
|
|
||||||
if (reject.status === 409) return; // смена подписанной ведомости неизменяема — верно
|
|
||||||
expect(reject.status, describeResponse(reject)).toBe(200);
|
|
||||||
expect(
|
|
||||||
await ledgerTotal(employee!.id),
|
|
||||||
'после отклонения оплаченной смены должно быть сторно: итог начислений 0',
|
|
||||||
).toBe(0n);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K7/D2] повторный импорт книги: итог в реестре равен сумме по книге (две точки в один день)', async () => {
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const date = isoDate(daysAgo(5));
|
|
||||||
const keyA = `xlsx:payroll:accrual:e2e-${randomUlid()}-unit-a`;
|
|
||||||
const keyB = `xlsx:payroll:accrual:e2e-${randomUlid()}-unit-b`;
|
|
||||||
const entry = (externalKey: string, amount: number) => ({
|
|
||||||
external_key: externalKey,
|
|
||||||
employee_external_ref: employee!.external_ref,
|
|
||||||
business_date: date,
|
|
||||||
kind: 'accrual',
|
|
||||||
amount_minor: amount,
|
|
||||||
});
|
|
||||||
const importBook = (amountA: number, amountB: number) =>
|
|
||||||
api('POST', '/imports/owner-workbook', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
source_name: synthetic('import-book'),
|
|
||||||
payroll_entries: [entry(keyA, amountA), entry(keyB, amountB)],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = await importBook(11_100, 22_200);
|
|
||||||
expect(first.status, describeResponse(first)).toBeLessThan(300);
|
|
||||||
const afterFirst = await ledgerTotal(employee!.id);
|
|
||||||
const second = await importBook(15_500, 22_200);
|
|
||||||
expect(second.status, describeResponse(second)).toBeLessThan(300);
|
|
||||||
const afterSecond = await ledgerTotal(employee!.id);
|
|
||||||
expect({ afterFirst, afterSecond }).toEqual({
|
|
||||||
afterFirst: 33_300n, // 111,00 ₽ + 222,00 ₽ по книге
|
|
||||||
afterSecond: 37_700n, // 155,00 ₽ + 222,00 ₽ после исправления суммы точки A
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K8/D1] подписанная ведомость воспроизводится после смены ставки (сумма та же)', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
await assignHourly(employee!.id, unit!.id);
|
|
||||||
const day = daysAgo(6);
|
|
||||||
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
|
|
||||||
await confirmShift(sessionId);
|
|
||||||
const runId = await calculateAndApprove(unit!.id, day);
|
|
||||||
const signed = await api('GET', `/payroll/runs/${runId}`, { token: ctx().managerToken });
|
|
||||||
expect(signed.status, describeResponse(signed)).toBe(200);
|
|
||||||
|
|
||||||
// Меняем ставку «задним числом» (то же effective_from) после подписи.
|
|
||||||
const change = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
pay_rule_id: await payRuleId('hourly'),
|
|
||||||
org_unit_id: unit!.id,
|
|
||||||
rate_minor: SYNTHETIC_RATE_MINOR * 3,
|
|
||||||
effective_from: '2026-01-01',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(change.status, describeResponse(change)).toBe(200);
|
|
||||||
|
|
||||||
// Предполагаемый контракт C4 (NOTES-FOR-EXECUTORS): пересчёт подписанной ведомости
|
|
||||||
// для проверки по снимку правил и ставок на момент подписи.
|
|
||||||
const verify = await api('GET', `/payroll/runs/${runId}/verify`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
});
|
|
||||||
expect(verify.status, `C4 verify: ${describeResponse(verify)}`).toBe(200);
|
|
||||||
expect({
|
|
||||||
matches: verify.body.matches,
|
|
||||||
recalculated: String(verify.body.recalculated_total_minor),
|
|
||||||
}).toEqual({ matches: true, recalculated: String(8 * SYNTHETIC_RATE_MINOR) });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[K9/D3] официант по пресету получает оплату смены из параметров пресета', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const [employee] = await importEmployees(1);
|
|
||||||
const shiftRate = 111_100; // синтетические 1 111,00 ₽ за смену (кратно рублю, см. округление)
|
|
||||||
// Предполагаемый контракт: параметры пресета передаются объектом `params`
|
|
||||||
// (ключи — param_declarations правила).
|
|
||||||
const assign = await api('PUT', `/employees/${employee!.id}/pay-assignment`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
pay_rule_id: await payRuleId('waiter_upsell'),
|
|
||||||
org_unit_id: unit!.id,
|
|
||||||
effective_from: '2026-01-01',
|
|
||||||
params: {
|
|
||||||
shift_rate_minor: shiftRate,
|
|
||||||
percent_bp: 0,
|
|
||||||
upsell_rate_minor: 0,
|
|
||||||
avg_check_target_minor: 99_999_999,
|
|
||||||
avg_check_bonus_minor: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(assign.status, describeResponse(assign)).toBe(200);
|
|
||||||
const saved = await api('GET', `/employees/${employee!.id}/pay-assignment`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
saved.body.params?.shift_rate_minor,
|
|
||||||
`параметр пресета должен сохраниться: ${describeResponse(saved)}`,
|
|
||||||
).toBe(shiftRate);
|
|
||||||
|
|
||||||
const day = daysAgo(7);
|
|
||||||
const sessionId = await workedShift(employee!.id, unit!.id, day, 8);
|
|
||||||
await confirmShift(sessionId);
|
|
||||||
await calculateAndApprove(unit!.id, day);
|
|
||||||
expect(await ledgerTotal(employee!.id)).toBe(BigInt(shiftRate));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[S9] удаление точки с привязанным устройством: удалена или понятный отказ, не 23502', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const device = await api('POST', '/sync/devices', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
device_code: `E2E-${randomUlid().slice(-10)}`,
|
|
||||||
name: synthetic('Терминал S9'),
|
|
||||||
org_unit_id: unit!.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(device.status, describeResponse(device)).toBe(201);
|
|
||||||
// Публичного API удаления точки нет, поэтому удаляем так же, как аудит владельца:
|
|
||||||
// SQL-запросом владельца схемы (мигратор), в tenant-контексте (FORCE RLS).
|
|
||||||
const migrator = new pg.Client({ connectionString: ctx().migrationDatabaseUrl });
|
|
||||||
await migrator.connect();
|
|
||||||
let outcome = 'deleted';
|
|
||||||
try {
|
|
||||||
await migrator.query('BEGIN');
|
|
||||||
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [ctx().tenantId]);
|
|
||||||
await migrator.query('DELETE FROM staff_org_units WHERE id = $1', [unit!.id]);
|
|
||||||
await migrator.query('ROLLBACK');
|
|
||||||
} catch (error) {
|
|
||||||
outcome = `${(error as { code?: string }).code} ${(error as Error).message}`;
|
|
||||||
await migrator.query('ROLLBACK').catch(() => undefined);
|
|
||||||
} finally {
|
|
||||||
await migrator.end();
|
|
||||||
}
|
|
||||||
// Верно: удалено, либо отказ внешнего ключа 23503 (понятный запрет).
|
|
||||||
expect(outcome, 'удаление точки с устройством').toMatch(/^(deleted|23503 )/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[A4] ответы 500 не содержат кода и текста ошибки PostgreSQL', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const probes: Array<[string, ApiResponse]> = [
|
|
||||||
[
|
|
||||||
'POST /employees',
|
|
||||||
await api('POST', '/employees', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
personnel_number: `E2E-${randomUlid().slice(-8)}`,
|
|
||||||
display_name: synthetic('Сотрудник'),
|
|
||||||
position: 'E2E-должность',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'POST /payroll/calculate month=2026-13',
|
|
||||||
await api('POST', '/payroll/calculate', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { month: '2026-13', org_unit_id: unit!.id },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
const leaks = probes
|
|
||||||
.filter(([, response]) => response.status >= 500)
|
|
||||||
.map(([name, response]) => ({ name, leaks: postgresLeak(response) }))
|
|
||||||
.filter((item) => item.leaks.length > 0);
|
|
||||||
expect(leaks, 'тела 500 с деталями PostgreSQL').toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[N12-DATE] расчёт с несуществующей датой (month=2026-13, 2026-02-30) → 400', async () => {
|
|
||||||
const [unit] = await createOrgUnits(1);
|
|
||||||
const month = await api('POST', '/payroll/calculate', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { month: '2026-13', org_unit_id: unit!.id },
|
|
||||||
});
|
|
||||||
const range = await api('POST', '/payroll/calculate', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { from: '2026-02-01', to: '2026-02-30', org_unit_id: unit!.id },
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
{ month: month.status, range: range.status },
|
|
||||||
`${describeResponse(month)} | ${describeResponse(range)}`,
|
|
||||||
).toEqual({ month: 400, range: 400 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,584 +0,0 @@
|
||||||
// PILOT-REHOME (TASK-GROK-3 п.5): приёмочный сценарий для будущего scripts/rehome-tenant.mjs
|
|
||||||
// (TASK-CODEX-2 п.2.2) — перенос данных пилота с заглушки арендатора на настоящий tenant.
|
|
||||||
// Предполагаемый контракт (NOTES-FOR-EXECUTORS.md, C6):
|
|
||||||
// DATABASE_ADMIN_URL=<админ БД> node scripts/rehome-tenant.mjs --from <ulid> --to <ulid> [--dry-run] [--json]
|
|
||||||
// --json печатает (последней строкой) {"tables":{"<таблица>":<строк>},"ledger_amount_minor_sum":<число>}.
|
|
||||||
// Таблицы берутся из information_schema.columns (column_name = 'tenant_id'), а не списком вручную:
|
|
||||||
// новая таблица без сида делает красным [PILOT-REHOME-SEED], чтобы перенос не забыл её.
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import pg from 'pg';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { ctx, dbQueryAs } from './harness/client.js';
|
|
||||||
import { randomUlid } from './harness/context.js';
|
|
||||||
import { baseEnv } from '../../scripts/test/node-commands.mjs';
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(import.meta.dirname, '../..');
|
|
||||||
const scriptPath = path.join(repoRoot, 'scripts/rehome-tenant.mjs');
|
|
||||||
const STUB = '01J00000000000000000000000';
|
|
||||||
const TARGET = '01M2R6AC5652NPT3MG34DKN4ZP';
|
|
||||||
const FOREIGN = randomUlid();
|
|
||||||
|
|
||||||
type RehomeReport = { tables?: Record<string, number>; ledger_amount_minor_sum?: number | string };
|
|
||||||
type Snapshot = { counts: Record<string, number>; ledgerSum: string };
|
|
||||||
|
|
||||||
async function withMigrator<T>(
|
|
||||||
tenant: string,
|
|
||||||
fn: (client: pg.Client) => Promise<T>,
|
|
||||||
userId?: string,
|
|
||||||
connectionString = ctx().migrationDatabaseUrl,
|
|
||||||
): Promise<T> {
|
|
||||||
const client = new pg.Client({ connectionString });
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenant]);
|
|
||||||
// Политики INSERT журнала аудита, начислений, отметок и табеля требуют app.user_id = автор.
|
|
||||||
if (userId) await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);
|
|
||||||
const result = await fn(client);
|
|
||||||
await client.query('COMMIT');
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK').catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tenantTables(): Promise<string[]> {
|
|
||||||
return withMigrator(STUB, async (client) => {
|
|
||||||
const result = await client.query<{ table_name: string }>(
|
|
||||||
`SELECT DISTINCT c.table_name FROM information_schema.columns c
|
|
||||||
JOIN information_schema.tables t USING (table_schema, table_name)
|
|
||||||
WHERE c.table_schema = 'public' AND c.column_name = 'tenant_id' AND t.table_type = 'BASE TABLE'
|
|
||||||
ORDER BY 1`,
|
|
||||||
);
|
|
||||||
return result.rows.map((row) => row.table_name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function snapshot(tenant: string, tables: string[]): Promise<Snapshot> {
|
|
||||||
return withMigrator(tenant, async (client) => {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const table of tables) {
|
|
||||||
const result = await client.query<{ n: number }>(
|
|
||||||
`SELECT count(*)::int AS n FROM ${table} WHERE tenant_id = $1`,
|
|
||||||
[tenant],
|
|
||||||
);
|
|
||||||
counts[table] = result.rows[0]!.n;
|
|
||||||
}
|
|
||||||
const sum = await client.query<{ s: string }>(
|
|
||||||
'SELECT coalesce(sum(amount_minor), 0)::text AS s FROM staff_payroll_ledger WHERE tenant_id = $1',
|
|
||||||
[tenant],
|
|
||||||
);
|
|
||||||
return { counts, ledgerSum: sum.rows[0]!.s };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Удаляет строки арендатора из всех таблиц (дочерние раньше родительских — повторными проходами).
|
|
||||||
* У staff_payroll_ledger нет политики DELETE, поэтому под FORCE RLS мигратор его не очистит:
|
|
||||||
* при наличии админа БД чистит админ, иначе остаток ловит проверка ниже.
|
|
||||||
*/
|
|
||||||
async function purge(tenant: string, tables: string[]) {
|
|
||||||
const connection = ctx().adminDatabaseUrl ?? ctx().migrationDatabaseUrl;
|
|
||||||
await withMigrator(
|
|
||||||
tenant,
|
|
||||||
async (client) => {
|
|
||||||
let pending = [...tables];
|
|
||||||
for (let pass = 0; pass < tables.length && pending.length > 0; pass += 1) {
|
|
||||||
const failed: string[] = [];
|
|
||||||
for (const table of pending) {
|
|
||||||
await client.query('SAVEPOINT purge');
|
|
||||||
try {
|
|
||||||
await client.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenant]);
|
|
||||||
await client.query('RELEASE SAVEPOINT purge');
|
|
||||||
} catch {
|
|
||||||
await client.query('ROLLBACK TO SAVEPOINT purge');
|
|
||||||
failed.push(table);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pending = failed;
|
|
||||||
}
|
|
||||||
if (pending.length) throw new Error(`не удалось очистить ${tenant}: ${pending.join(', ')}`);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
connection,
|
|
||||||
);
|
|
||||||
const left = Object.entries((await snapshot(tenant, tables)).counts).filter(([, n]) => n > 0);
|
|
||||||
if (left.length) {
|
|
||||||
throw new Error(
|
|
||||||
`после очистки у ${tenant} остались строки (нужен админ БД: E2E_DB_ADMIN_URL): ${left.map(([t, n]) => `${t}=${n}`).join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hex64 = () => randomBytes(32).toString('hex');
|
|
||||||
const code = (prefix: string) => `${prefix}_${randomBytes(4).toString('hex')}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Синтетические данные арендатора: по 2+ строки в каждой известной таблице с tenant_id.
|
|
||||||
* Возвращает id сотрудников и список засеянных таблиц. Пишет мигратор (владелец таблиц),
|
|
||||||
* FORCE RLS действует и на него, поэтому app.tenant_id выставлен в транзакции.
|
|
||||||
*/
|
|
||||||
async function seed(tenant: string) {
|
|
||||||
const ids = (n: number) => Array.from({ length: n }, () => randomUlid());
|
|
||||||
const actor = randomUlid();
|
|
||||||
const corr = () => randomUlid();
|
|
||||||
const seeded = new Set<string>();
|
|
||||||
const employees = ids(3);
|
|
||||||
const amounts = [150_000n, 72_300n, -12_300n].map(
|
|
||||||
(base) => base + BigInt(randomBytes(2).readUInt16BE(0)),
|
|
||||||
);
|
|
||||||
await withMigrator(
|
|
||||||
tenant,
|
|
||||||
async (client) => {
|
|
||||||
const q = async (table: string, sql: string, params: unknown[]) => {
|
|
||||||
await client.query(sql, params);
|
|
||||||
seeded.add(table);
|
|
||||||
};
|
|
||||||
const [ou1, ou2] = ids(2);
|
|
||||||
for (const [i, ou] of [ou1, ou2].entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_org_units',
|
|
||||||
'INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1,$2,$3,$4)',
|
|
||||||
[ou, tenant, `rehome-${i}-${randomBytes(3).toString('hex')}`, `Точка ${i + 1}`],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const professions = ids(2);
|
|
||||||
for (const [i, id] of professions.entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_professions',
|
|
||||||
'INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1,$2,$3,$4)',
|
|
||||||
[id, tenant, code('prof'), `Профессия ${i}-${randomBytes(3).toString('hex')}`],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [i, id] of employees.entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_employees',
|
|
||||||
'INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, primary_org_unit_id, profession_id) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
||||||
[
|
|
||||||
id,
|
|
||||||
tenant,
|
|
||||||
`R-${randomBytes(4).toString('hex')}`,
|
|
||||||
`Сотрудник ${i + 1}`,
|
|
||||||
i % 2 ? ou2 : ou1,
|
|
||||||
professions[i % 2],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const metricKeys = [code('metric'), code('metric')];
|
|
||||||
for (const key of metricKeys) {
|
|
||||||
await q(
|
|
||||||
'staff_metric_definitions',
|
|
||||||
'INSERT INTO staff_metric_definitions (id, tenant_id, key, label) VALUES ($1,$2,$3,$4)',
|
|
||||||
[randomUlid(), tenant, key, `Метрика ${key}`],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [i, key] of metricKeys.entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_daily_metrics',
|
|
||||||
'INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, recorded_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
||||||
[randomUlid(), tenant, ou1, `2026-09-0${i + 1}`, key, 1000 + i, actor],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [i, ou] of [ou1, ou2].entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_org_unit_terms',
|
|
||||||
'INSERT INTO staff_org_unit_terms (id, tenant_id, org_unit_id, effective_from, created_by) VALUES ($1,$2,$3,$4,$5)',
|
|
||||||
[randomUlid(), tenant, ou, `2026-0${i + 1}-01`, actor],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const rules = ids(2);
|
|
||||||
for (const [i, id] of rules.entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_pay_rules',
|
|
||||||
'INSERT INTO staff_pay_rules (id, tenant_id, code, version, name, rule, effective_from, created_by) VALUES ($1,$2,$3,1,$4,$5,$6,$7)',
|
|
||||||
[
|
|
||||||
id,
|
|
||||||
tenant,
|
|
||||||
code('rule'),
|
|
||||||
`Правило ${i}`,
|
|
||||||
JSON.stringify({ kind: 'hourly', rate_minor: 12_300 }),
|
|
||||||
'2026-01-01',
|
|
||||||
actor,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_pay_assignments',
|
|
||||||
'INSERT INTO staff_pay_assignments (id, tenant_id, employee_id, pay_rule_id, effective_from, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
||||||
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const seedSessionIds: string[] = [];
|
|
||||||
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_attendance_events',
|
|
||||||
'INSERT INTO staff_attendance_events (id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, actor_id, correlation_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
|
||||||
[
|
|
||||||
randomUlid(),
|
|
||||||
tenant,
|
|
||||||
emp,
|
|
||||||
i ? 'check_out' : 'check_in',
|
|
||||||
'terminal',
|
|
||||||
`src-${randomBytes(6).toString('hex')}`,
|
|
||||||
'2026-09-01T09:00:00Z',
|
|
||||||
actor,
|
|
||||||
corr(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const sid = randomUlid();
|
|
||||||
seedSessionIds.push(sid);
|
|
||||||
await q(
|
|
||||||
'staff_work_sessions',
|
|
||||||
'INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1,$2,$3,$4,$5)',
|
|
||||||
[sid, tenant, emp, ou1, '2026-09-01'],
|
|
||||||
);
|
|
||||||
|
|
||||||
await q(
|
|
||||||
'staff_timesheet_entries',
|
|
||||||
'INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
|
||||||
[randomUlid(), tenant, emp, '2026-09-01', actor],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_work_items',
|
|
||||||
'INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
||||||
[randomUlid(), tenant, emp, ou1, '2026-09-01', 'rehome_item', actor],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const runs = ids(2);
|
|
||||||
for (const [i, run] of runs.entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_payroll_runs',
|
|
||||||
'INSERT INTO staff_payroll_runs (id, tenant_id, period_start, period_end, created_by) VALUES ($1,$2,$3,$4,$5)',
|
|
||||||
[run, tenant, `2026-0${i + 7}-01`, `2026-0${i + 7}-28`, actor],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_payroll_lines',
|
|
||||||
'INSERT INTO staff_payroll_lines (id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id, rule_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
|
|
||||||
[randomUlid(), tenant, run, employees[i], ou1, `2026-0${i + 7}-02`, rules[i], 'hourly'],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_payouts',
|
|
||||||
'INSERT INTO staff_payouts (id, tenant_id, employee_id, paid_on, amount_minor, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
||||||
[randomUlid(), tenant, employees[i], `2026-0${i + 8}-05`, 50_000 + i, actor],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [i, amount] of amounts.entries()) {
|
|
||||||
await q(
|
|
||||||
'staff_payroll_ledger',
|
|
||||||
'INSERT INTO staff_payroll_ledger (id, tenant_id, employee_id, business_date, kind, amount_minor, ref_type, correlation_id, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
|
||||||
[
|
|
||||||
randomUlid(),
|
|
||||||
tenant,
|
|
||||||
employees[i % 2],
|
|
||||||
'2026-09-01',
|
|
||||||
amount < 0n ? 'adjustment' : 'accrual',
|
|
||||||
amount.toString(),
|
|
||||||
'manual',
|
|
||||||
corr(),
|
|
||||||
actor,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const outbox = ids(2);
|
|
||||||
for (const id of outbox) {
|
|
||||||
await q(
|
|
||||||
'staff_outbox',
|
|
||||||
'INSERT INTO staff_outbox (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at) VALUES ($1,$2,$3,1,$4,$5,$6,$7,$8,$9,now())',
|
|
||||||
[id, tenant, 'staff.rehome_seed', 'employee', employees[0], 'user', actor, '{}', corr()],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 2; i += 1) {
|
|
||||||
await q(
|
|
||||||
'staff_inbox',
|
|
||||||
'INSERT INTO staff_inbox (event_id, tenant_id, event_type, event_version, correlation_id) VALUES ($1,$2,$3,1,$4)',
|
|
||||||
[randomUlid(), tenant, 'core.rehome_seed', corr()],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_idempotency_keys',
|
|
||||||
"INSERT INTO staff_idempotency_keys (tenant_id, key, request_hash, expires_at) VALUES ($1,$2,$3, now() + interval '1 day')",
|
|
||||||
[tenant, `idem-${randomBytes(8).toString('hex')}`, hex64()],
|
|
||||||
);
|
|
||||||
const endpoint = `https://push.example.test/${randomBytes(8).toString('hex')}`;
|
|
||||||
await q(
|
|
||||||
'staff_push_subscriptions',
|
|
||||||
'INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
||||||
[
|
|
||||||
randomUlid(),
|
|
||||||
tenant,
|
|
||||||
employees[i],
|
|
||||||
endpoint,
|
|
||||||
randomBytes(16).toString('base64url'),
|
|
||||||
randomBytes(8).toString('base64url'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_notification_deliveries',
|
|
||||||
'INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint) VALUES ($1,$2,$3,$4,$5)',
|
|
||||||
[randomUlid(), tenant, employees[i], outbox[i], endpoint],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_standalone_employee_access',
|
|
||||||
'INSERT INTO staff_standalone_employee_access (id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
||||||
[
|
|
||||||
randomUlid(),
|
|
||||||
tenant,
|
|
||||||
employees[i],
|
|
||||||
randomUlid(),
|
|
||||||
createHash('sha256').update(randomBytes(32)).digest('hex'),
|
|
||||||
'e2e…',
|
|
||||||
actor,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_sync_change_log',
|
|
||||||
'INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation) VALUES ($1,$2,$3,$4,$5)',
|
|
||||||
[randomUlid(), tenant, 'employee', employees[i], 'upsert'],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_audit_log',
|
|
||||||
'INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, correlation_id) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
||||||
[randomUlid(), tenant, actor, 'employee.seeded', 'employee', employees[i], corr()],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const devices = ids(2);
|
|
||||||
for (const device of devices) {
|
|
||||||
await q(
|
|
||||||
'staff_sync_devices',
|
|
||||||
'INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
||||||
[
|
|
||||||
device,
|
|
||||||
tenant,
|
|
||||||
`dev-${randomBytes(4).toString('hex')}`,
|
|
||||||
'Терминал',
|
|
||||||
hex64(),
|
|
||||||
'kstaff…',
|
|
||||||
actor,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_sync_command_inbox',
|
|
||||||
'INSERT INTO staff_sync_command_inbox (id, tenant_id, device_id, command_type, idempotency_key, correlation_id, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,now())',
|
|
||||||
[
|
|
||||||
randomUlid(),
|
|
||||||
tenant,
|
|
||||||
device,
|
|
||||||
'record_attendance',
|
|
||||||
`idem-${randomBytes(6).toString('hex')}`,
|
|
||||||
corr(),
|
|
||||||
'applied',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_sync_device_checkpoints',
|
|
||||||
'INSERT INTO staff_sync_device_checkpoints (device_id, tenant_id) VALUES ($1,$2)',
|
|
||||||
[device, tenant],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 2; i += 1) {
|
|
||||||
await q(
|
|
||||||
'staff_access_grants',
|
|
||||||
'INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
||||||
[
|
|
||||||
randomUlid(),
|
|
||||||
tenant,
|
|
||||||
i === 0 ? 'accountant' : 'director',
|
|
||||||
`Роль ${i}`,
|
|
||||||
hex64(),
|
|
||||||
'hint…',
|
|
||||||
actor,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const batchId = randomUlid();
|
|
||||||
await q(
|
|
||||||
'staff_payroll_batches',
|
|
||||||
'INSERT INTO staff_payroll_batches (id, tenant_id, org_unit_id, period_start, period_end, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
||||||
[batchId, tenant, ou1, `2026-0${i + 4}-01`, `2026-0${i + 4}-28`, actor],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_payroll_batch_lines',
|
|
||||||
'INSERT INTO staff_payroll_batch_lines (id, tenant_id, batch_id, work_session_id, employee_id, org_unit_id, amount_minor) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
||||||
[randomUlid(), tenant, batchId, seedSessionIds[i]!, employees[i]!, ou1, 100_000 + i],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_batch_notifications',
|
|
||||||
'INSERT INTO staff_batch_notifications (id, tenant_id, recipient_role, type, subject_id) VALUES ($1,$2,$3,$4,$5)',
|
|
||||||
[randomUlid(), tenant, 'director', 'payroll.batch.submitted', batchId],
|
|
||||||
);
|
|
||||||
await q(
|
|
||||||
'staff_biometric_consents',
|
|
||||||
'INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
|
||||||
[tenant, employees[i]!, 'granted', `doc-${i}`, actor],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
actor,
|
|
||||||
);
|
|
||||||
return { employees, seeded };
|
|
||||||
}
|
|
||||||
|
|
||||||
function runRehome(args: string[], adminUrl: string) {
|
|
||||||
const result = spawnSync(
|
|
||||||
process.execPath,
|
|
||||||
[scriptPath, '--from', STUB, '--to', TARGET, ...args],
|
|
||||||
{
|
|
||||||
cwd: repoRoot,
|
|
||||||
env: { ...baseEnv(), DATABASE_ADMIN_URL: adminUrl },
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const lines = (result.stdout ?? '').trim().split('\n');
|
|
||||||
let report: RehomeReport | null = null;
|
|
||||||
for (const line of [...lines].reverse()) {
|
|
||||||
if (!line.trim().startsWith('{')) continue;
|
|
||||||
try {
|
|
||||||
report = JSON.parse(line) as RehomeReport;
|
|
||||||
break;
|
|
||||||
} catch {
|
|
||||||
// не JSON — ищем дальше
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: result.status,
|
|
||||||
stdout: result.stdout ?? '',
|
|
||||||
stderr: result.stderr ?? '',
|
|
||||||
report,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const state: {
|
|
||||||
tables: string[];
|
|
||||||
employees: string[];
|
|
||||||
before?: { stub: Snapshot; foreign: Snapshot };
|
|
||||||
} = { tables: [], employees: [] };
|
|
||||||
|
|
||||||
describe('PILOT-REHOME: перенос заглушки арендатора на настоящий tenant', () => {
|
|
||||||
it('[PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema)', async () => {
|
|
||||||
state.tables = await tenantTables();
|
|
||||||
expect(state.tables.length).toBeGreaterThan(0);
|
|
||||||
await purge(STUB, state.tables);
|
|
||||||
await purge(TARGET, state.tables);
|
|
||||||
const stub = await seed(STUB);
|
|
||||||
const foreign = await seed(FOREIGN);
|
|
||||||
state.employees = stub.employees;
|
|
||||||
const unseeded = state.tables.filter((table) => !stub.seeded.has(table));
|
|
||||||
expect(
|
|
||||||
unseeded,
|
|
||||||
`таблицы с tenant_id без сида в tests/e2e/60-pilot-rehome.e2e.ts — перенос арендатора их не проверит: ${unseeded.join(', ')}`,
|
|
||||||
).toEqual([]);
|
|
||||||
const stubSnap = await snapshot(STUB, state.tables);
|
|
||||||
const foreignSnap = await snapshot(FOREIGN, state.tables);
|
|
||||||
const empty = state.tables.filter((t) => stubSnap.counts[t]! < 1 || foreignSnap.counts[t]! < 1);
|
|
||||||
expect(empty, `сид не виден через RLS: ${empty.join(', ')}`).toEqual([]);
|
|
||||||
expect(Object.values((await snapshot(TARGET, state.tables)).counts).every((n) => n === 0)).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(foreign.seeded.size).toBe(stub.seeded.size);
|
|
||||||
state.before = { stub: stubSnap, foreign: foreignSnap };
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает', async () => {
|
|
||||||
const before = state.before;
|
|
||||||
expect(before, 'сид не выполнен (см. [PILOT-REHOME-SEED])').toBeDefined();
|
|
||||||
if (!existsSync(scriptPath)) {
|
|
||||||
throw new Error(
|
|
||||||
'scripts/rehome-tenant.mjs отсутствует (TASK-CODEX-2 п.2.2): перенос заглушки ' +
|
|
||||||
`${STUB} → ${TARGET} не реализован; контракт сценария — NOTES-FOR-EXECUTORS.md C6. ` +
|
|
||||||
`Засеяно таблиц: ${state.tables.length}, ledger SUM(amount_minor)=${before!.stub.ledgerSum}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const adminUrl = ctx().adminDatabaseUrl;
|
|
||||||
if (!adminUrl) {
|
|
||||||
throw new Error('нет админа БД: задайте E2E_DB_ADMIN_URL или E2E_PG_ADMIN_URL');
|
|
||||||
}
|
|
||||||
const expectedTables = Object.fromEntries(
|
|
||||||
Object.entries(before!.stub.counts).filter(([, n]) => n > 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1. --dry-run: печатает те же числа и ничего не меняет.
|
|
||||||
const dry = runRehome(['--dry-run', '--json'], adminUrl);
|
|
||||||
expect(dry.status, `dry-run exit: ${dry.stderr}`).toBe(0);
|
|
||||||
expect(dry.report, `dry-run без JSON-отчёта: ${dry.stdout}`).not.toBeNull();
|
|
||||||
const dryTables = Object.fromEntries(
|
|
||||||
Object.entries(dry.report!.tables ?? {}).filter(([, n]) => Number(n) > 0),
|
|
||||||
);
|
|
||||||
expect(dryTables).toEqual(expectedTables);
|
|
||||||
expect(String(dry.report!.ledger_amount_minor_sum)).toBe(before!.stub.ledgerSum);
|
|
||||||
expect(await snapshot(STUB, state.tables), '--dry-run изменил данные заглушки').toEqual(
|
|
||||||
before!.stub,
|
|
||||||
);
|
|
||||||
expect(Object.values((await snapshot(TARGET, state.tables)).counts).every((n) => n === 0)).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Перенос ролью администратора БД.
|
|
||||||
const real = runRehome(['--json'], adminUrl);
|
|
||||||
expect(real.status, `перенос exit: ${real.stderr}`).toBe(0);
|
|
||||||
const afterStub = await snapshot(STUB, state.tables);
|
|
||||||
const afterTarget = await snapshot(TARGET, state.tables);
|
|
||||||
const leftovers = state.tables.filter((t) => afterStub.counts[t] !== 0);
|
|
||||||
expect(leftovers, 'под заглушкой остались строки').toEqual([]);
|
|
||||||
for (const table of state.tables) {
|
|
||||||
const expected = before!.stub.counts[table]!;
|
|
||||||
if (table === 'staff_audit_log')
|
|
||||||
expect(
|
|
||||||
afterTarget.counts[table]!,
|
|
||||||
'staff_audit_log: у нового арендатора нет новой записи о переносе',
|
|
||||||
).toBeGreaterThan(expected);
|
|
||||||
else expect(afterTarget.counts[table], table).toBe(expected);
|
|
||||||
}
|
|
||||||
expect(afterTarget.ledgerSum).toBe(before!.stub.ledgerSum);
|
|
||||||
expect(await snapshot(FOREIGN, state.tables), 'посторонний арендатор изменён').toEqual(
|
|
||||||
before!.foreign,
|
|
||||||
);
|
|
||||||
const distinct = await withMigrator(TARGET, async (client) => {
|
|
||||||
const found: string[] = [];
|
|
||||||
for (const table of state.tables) {
|
|
||||||
const r = await client.query<{ tenant_id: string }>(
|
|
||||||
`SELECT DISTINCT tenant_id FROM ${table} WHERE tenant_id IN ($1, $2)`,
|
|
||||||
[STUB, TARGET],
|
|
||||||
);
|
|
||||||
found.push(...r.rows.map((row) => `${table}:${row.tenant_id}`));
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
});
|
|
||||||
expect(distinct.filter((d) => d.endsWith(STUB))).toEqual([]);
|
|
||||||
|
|
||||||
// 3. Запись аудита у нового арендатора.
|
|
||||||
const audit = await dbQueryAs<{ action: string }>(
|
|
||||||
TARGET,
|
|
||||||
"SELECT action FROM staff_audit_log WHERE tenant_id = $1 AND action ILIKE '%rehome%'",
|
|
||||||
[TARGET],
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
audit.length,
|
|
||||||
'нет записи staff_audit_log о переносе у нового арендатора',
|
|
||||||
).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// 4. Роль приложения видит тех же сотрудников под новым tenant и ничего под старым.
|
|
||||||
const underTarget = await dbQueryAs<{ id: string }>(
|
|
||||||
TARGET,
|
|
||||||
'SELECT id FROM staff_employees ORDER BY id',
|
|
||||||
);
|
|
||||||
expect(underTarget.map((row) => row.id)).toEqual([...state.employees].sort());
|
|
||||||
expect(await dbQueryAs(STUB, 'SELECT id FROM staff_employees')).toEqual([]);
|
|
||||||
|
|
||||||
// 5. Журнал начислений по-прежнему append-only для приложения.
|
|
||||||
await expect(
|
|
||||||
dbQueryAs(TARGET, 'UPDATE staff_payroll_ledger SET amount_minor = amount_minor + 1'),
|
|
||||||
).rejects.toThrow(/permission denied|append-only|read-only/i);
|
|
||||||
|
|
||||||
// 6. Повторный запуск отказывает и ничего не меняет.
|
|
||||||
const again = runRehome(['--json'], adminUrl);
|
|
||||||
expect(again.status, 'повторный перенос должен отказать').not.toBe(0);
|
|
||||||
expect(await snapshot(TARGET, state.tables)).toEqual(afterTarget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
# Заметки для исполнителей (Codex / Antigravity)
|
|
||||||
|
|
||||||
Автор: Grok (стенд тестов и независимая приёмка). Я не меняю `src/`, `apps/`,
|
|
||||||
`migrations/`, `deploy/`; всё, что требует изменения продукта, описано здесь.
|
|
||||||
|
|
||||||
Как запускать:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# bootstrap-админ PostgreSQL 17 (только создаёт роли и БД; сервер и миграции под ним не работают)
|
|
||||||
export E2E_PG_ADMIN_URL=postgres://<admin>:<pass>@127.0.0.1:55433/postgres
|
|
||||||
node scripts/test/run-e2e.mjs # храповик known-defects (как в CI)
|
|
||||||
node scripts/test/run-e2e.mjs --no-known-defects # строгий режим: все дефекты красные
|
|
||||||
E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # + откат всех миграций перед запуском
|
|
||||||
./scripts/test/ci-local.sh # локальный эквивалент verify/format/e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
**Правило храповика.** Исправили дефект → его сценарий позеленел →
|
|
||||||
`run-e2e.mjs` падает с «дефект исправлен, обновите known-defects.json» —
|
|
||||||
удалите ID из `tests/e2e/known-defects.json` в том же коммите. Новый красный
|
|
||||||
сценарий, которого нет в списке, — регрессия.
|
|
||||||
|
|
||||||
## 1. Предполагаемый контракт для решений владельца (OWN-01..11) — Codex, этап 4
|
|
||||||
|
|
||||||
API пока нет, поэтому сценарии `tests/e2e/30-owner-decisions.e2e.ts` написаны
|
|
||||||
против наиболее вероятного контракта. Если выбираете другой — поменяйте тесты и
|
|
||||||
этот раздел в том же PR, смысл проверок (кто что может и когда) менять нельзя.
|
|
||||||
Все пути относительно `/api/v1/staff`.
|
|
||||||
|
|
||||||
### C1. Роли точки/бухгалтера/директора (standalone-аналог Core RBAC)
|
|
||||||
|
|
||||||
- `POST /access-grants` (только главный менеджер, `staff:employee:write`)
|
|
||||||
тело `{ "role": "point_manager" | "accountant" | "director", "org_unit_ids": ["<ULID>"], "display_name": "..." }`
|
|
||||||
→ `201 { "id", "role", "org_unit_ids", "access_token" }`. Токен показывается один раз,
|
|
||||||
хранится как хеш (как ключи сотрудников), используется как `Authorization: Bearer`.
|
|
||||||
- `point_manager` видит/считает только свои `org_unit_ids`; чужая точка → `403`
|
|
||||||
(и для нового `POST /payroll/batches`, и для старого `POST /payroll/calculate`).
|
|
||||||
- В platform-режиме те же роли приходят из Core (User ≠ Employee, AGENTS.md).
|
|
||||||
|
|
||||||
### C2. Пакет зарплаты точки и согласование
|
|
||||||
|
|
||||||
| Метод и путь | Кто | Результат |
|
|
||||||
| ------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `POST /payroll/batches` `{org_unit_id, from, to}` | accountant; point_manager своей точки | `201 {id, status:"draft", org_unit_id, lines:[{work_session_id, employee_id, org_unit_id, amount_minor}]}` |
|
|
||||||
| `POST /payroll/batches/:id/submit` | только accountant | `200 {status:"submitted"}`; point_manager → `403`; повтор после подписи → `409` |
|
|
||||||
| `GET /notifications` | любой пользователь | `200 {notifications:[{type:"payroll.batch.submitted", subject_id:<batchId>}]}`; каждому директору ровно одно уведомление на отправку |
|
|
||||||
| `POST /payroll/batches/:id/sign` | только director | `200 {status:"signed"}`; accountant → `403`; если смена пакета изменилась после отправки → `409`, пакет получает `status:"returned"` |
|
|
||||||
| `POST /payroll/batches/:id/return` `{comment}` | director | `200 {status:"returned", return_comment}` |
|
|
||||||
| `GET /payroll/batches/:id` | accountant, director | `200 {status, return_comment, lines:[...]}` |
|
|
||||||
|
|
||||||
Инварианты, которые проверяют тесты:
|
|
||||||
|
|
||||||
- до подписи в `staff_payroll_ledger` для сотрудников пакета нет ни `accrual`, ни `payout` (OWN-05);
|
|
||||||
- подпись атомарно пишет `accrual` и `payout`; после подписи `PATCH /work-sessions/:id`
|
|
||||||
для смен пакета → `409`, пакет не отправляется повторно (OWN-06);
|
|
||||||
- строка пакета принадлежит точке, где отработана смена (`staff_work_sessions.org_unit_id`),
|
|
||||||
а не основной точке сотрудника (OWN-09).
|
|
||||||
|
|
||||||
### C3. Согласие на биометрию
|
|
||||||
|
|
||||||
- `PUT /employees/:id/biometric-consent` `{ "status": "granted" | "revoked", "document_ref": "..." }`
|
|
||||||
(главный менеджер) → `200`.
|
|
||||||
- `GET /terminal/employees/:id/enrollment` (терминальный токен) →
|
|
||||||
`200 { "employee_id", "registration_allowed": boolean, "reason": "consent_missing" | "consent_revoked" | null }`.
|
|
||||||
Без записи о согласии — `false/consent_missing` (OWN-10); после отзыва — `false/consent_revoked` (OWN-11).
|
|
||||||
- Шаблоны отпечатков на сервер не передаются (docs/TERMINAL-API-REQUIREMENTS.md);
|
|
||||||
сервер хранит только факт согласия. Тот же флаг стоит отдать в `/sync/pull` (employee payload).
|
|
||||||
|
|
||||||
Сейчас все OWN красные с `404 Route ... not found` (C1–C3 не реализованы);
|
|
||||||
OWN-03..09 дополнительно упрутся в K3/N2 (подтверждение смены падает 500).
|
|
||||||
|
|
||||||
ID в названиях тестов — двойные «владелец/мой» (`[K3/N2]`, `[D6/OWN-01]`, `[K17/OWN-10]`,
|
|
||||||
`[K19/OWN-07]`), по таблице TASK-GROK-2 §2 и AUDIT.md.
|
|
||||||
|
|
||||||
### C4. Проверочный пересчёт подписанной ведомости (K8/D1)
|
|
||||||
|
|
||||||
- `GET /payroll/runs/:id/verify` (право `staff:payroll:read`) →
|
|
||||||
`200 { "run_id", "stored_total_minor", "recalculated_total_minor", "matches": boolean }`.
|
|
||||||
- Пересчёт идёт по снимку версий правил, параметров и ставок, действовавших при подписи
|
|
||||||
(и ревизиям смен), а не по текущим значениям. После смены ставки `matches` остаётся `true`.
|
|
||||||
|
|
||||||
### C5. Параметры пресета в назначении оплаты (K9/D3)
|
|
||||||
|
|
||||||
- `PUT /employees/:id/pay-assignment` принимает `params: { <key>: integer }`, ключи — из
|
|
||||||
`param_declarations` выбранного правила (например, `shift_rate_minor`, `percent_bp`,
|
|
||||||
`upsell_rate_minor`, `avg_check_target_minor`, `avg_check_bonus_minor` у `waiter_upsell`);
|
|
||||||
неизвестный ключ → 400. `GET .../pay-assignment` возвращает их в `params`.
|
|
||||||
- Старые поля `rate_minor`/`percent_bp`/`guarantee_minor` можно оставить как сокращения.
|
|
||||||
|
|
||||||
### C6. Перенос арендатора пилота (PILOT-REHOME, TASK-CODEX-2 п.2.2)
|
|
||||||
|
|
||||||
Что проверяет `tests/e2e/60-pilot-rehome.e2e.ts` (`[PILOT-REHOME]`):
|
|
||||||
|
|
||||||
- Запуск: `DATABASE_ADMIN_URL=<админ БД> node scripts/rehome-tenant.mjs --from 01J00000000000000000000000 --to 01M2R6AC5652NPT3MG34DKN4ZP [--dry-run] [--json]`.
|
|
||||||
Стенд передаёт `DATABASE_ADMIN_URL` суперпользователя в БД стенда (`E2E_DB_ADMIN_URL` или
|
|
||||||
`E2E_PG_ADMIN_URL` с именем БД).
|
|
||||||
- `--json`: последней строкой stdout — `{"tables":{"<таблица>":<строк у --from>},"ledger_amount_minor_sum":<число или строка>}`.
|
|
||||||
Таблицы с нулём можно не печатать.
|
|
||||||
- `--dry-run`: exit 0, те же числа, в БД ничего не меняется.
|
|
||||||
- Перенос: exit 0; под `--from` 0 строк во **всех** таблицах с `tenant_id` (список из
|
|
||||||
`information_schema.columns`, не вручную); у `--to` те же счётчики и та же
|
|
||||||
`SUM(amount_minor)` в `staff_payroll_ledger`; другие арендаторы не тронуты.
|
|
||||||
- В `staff_audit_log` у нового арендатора появляется запись с `action`, содержащим `rehome`
|
|
||||||
(например, `tenant.rehomed`, старый tenant — в `new_values`).
|
|
||||||
- После переноса `krona_staff_app` видит тех же сотрудников (те же `id`) под новым tenant и никого
|
|
||||||
под старым; `UPDATE staff_payroll_ledger` ролью приложения по-прежнему отклоняется.
|
|
||||||
- Повторный запуск (под `--from` пусто / у `--to` уже есть данные) — exit ≠ 0, ничего не меняет.
|
|
||||||
|
|
||||||
Что нужно учесть (видно по схеме, `pg_policy`/`pg_constraint` на PG17):
|
|
||||||
|
|
||||||
- У `staff_payroll_ledger` нет политики UPDATE/DELETE, у приложения нет права UPDATE, FORCE RLS
|
|
||||||
действует и на владельца: `krona_migrator` строки журнала не перенесёт. Нужна роль с обходом RLS
|
|
||||||
(суперпользователь/BYPASSRLS) — поэтому `DATABASE_ADMIN_URL`.
|
|
||||||
- Внешние ключи составные `(tenant_id, …)` и не DEFERRABLE: переносить родителя раньше детей
|
|
||||||
нельзя — нужен порядок, отложенные проверки или `session_replication_role = replica` в одной транзакции.
|
|
||||||
- Политики INSERT `staff_audit_log`, `staff_payroll_ledger`, `staff_attendance_events`,
|
|
||||||
`staff_timesheet_entries` требуют `actor_id/created_by/updated_by = current_setting('app.user_id')` —
|
|
||||||
для записи аудита без обхода RLS выставить `app.user_id`.
|
|
||||||
|
|
||||||
## 2. Дефекты продукта, найденные сценариями (все красные сейчас)
|
|
||||||
|
|
||||||
Команда и фактический вывод — `tests/e2e/evidence/e2e-run.txt`; таблица — `tests/e2e/REPORT.md`.
|
|
||||||
|
|
||||||
| ID | Файл | Что исправить |
|
|
||||||
| ---------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| K4/N1 | `src/portal.ts` POST /employees | `42P08 inconsistent types deduced for parameter $2`: явные `$2::varchar(26)`, `$5::text` или отдельный запрос профессии |
|
|
||||||
| K3/N2 | `src/portal.ts` PATCH /work-sessions, `src/domain/payroll/service.ts` calculate/approve | `staff_outbox.occurred_at` NULL (23502): передавать `occurred_at` или `DEFAULT now()` новой миграцией |
|
|
||||||
| K5/N4 | `service.ts` | после N2 проверить: пересекающийся период не должен начислять смену повторно (409 или исключение уже начисленных смен) |
|
|
||||||
| K12/N3 | push-подписки | allowlist push-сервисов / запрет приватных адресов, `redirect:'manual'`; сейчас сервер подключается к `127.0.0.1` |
|
|
||||||
| N9 | `push-dispatcher.ts` | `ON CONFLICT ... DO UPDATE SET employee_id` → 409 для чужого endpoint |
|
|
||||||
| N10 | GET /professions, /org-units | требовать право справочника; терминал сейчас получает 200 |
|
|
||||||
| N11 | импорт | валидировать `kind` и схему `items` → 400 вместо 500 (23514) |
|
|
||||||
| N12 | PUT pay-assignment | `additionalProperties:false`, верхние границы сумм |
|
|
||||||
| N12-DATE | `POST /payroll/calculate` | `month=2026-13` → 500 `22008`; проверять месяц 01–12 → 400 (диапазон `2026-02-30` уже даёт 400) |
|
|
||||||
| K6/D5 | `portal.ts` PATCH /work-sessions | смена подписанной ведомости: `409`, либо сторно в ledger; сейчас (на fix-live-database) 200 и начисление остаётся |
|
|
||||||
| K7/D2 | `operations.ts` импорт `payroll_entries` | дельта по сумме всех записей дня, вставка по ключу: две точки за день → 222,00 вместо 333,00 ₽; считать дельту по ключу |
|
|
||||||
| K8/D1 | расчёт/подпись | снимок правил и ставок в подписанной ведомости; контракт проверки C4 (ниже) |
|
|
||||||
| K9/D3 | PUT pay-assignment | сохранять `params` по `param_declarations` правила (C5 ниже); сейчас `shift_rate_minor` официанта теряется |
|
|
||||||
| S9 | миграция 0011 FK устройств | `ON DELETE SET NULL` на составном `(tenant_id, org_unit_id)` обнуляет `tenant_id` → 23502; `SET NULL (org_unit_id)` или RESTRICT |
|
|
||||||
| A4 | обработчик ошибок | в теле 5xx нет SQLSTATE (`code`) и текста PostgreSQL; подробности только в лог |
|
|
||||||
| K1/F2 | `config.ts`/`identity.ts` | standalone без токенов не должен выдавать права (процесс без токенов отдаёт 200 на /portal, /payroll, POST /professions) |
|
|
||||||
| K10/F3 | POST/DELETE /session | в cookie — идентификатор сессии, а не сырой токен; logout отзывает сессию |
|
|
||||||
| K11/F4 | вход | rate limit (тест ждёт 429 не позже 30 неверных попыток; идёт на отдельном процессе, чтобы лимит не мешал другим сценариям) |
|
|
||||||
| F7 | POST /attendance/events | использовать `Idempotency-Key` / `staff_idempotency_keys` |
|
|
||||||
| F10 | `assertSameOrigin` | мутации по cookie без `Origin` отклонять |
|
|
||||||
| K13/SYNC-A, K13/SYNC-B | `src/domain/sync/service.ts` | устройство не должно выставлять `confirmed`/`approved` (сейчас `applied`) |
|
|
||||||
| SYNC-C0 | `pull()` bootstrap | `SELECT ... status FROM staff_org_units` — такого столбца нет → 500 на любой начальной загрузке |
|
|
||||||
| S5/SYNC-C | `pull()` bootstrap | при `has_more=true` отдаётся `next_cursor=latest`, остаток после 100 сущностей теряется; нужен курсор снимка |
|
|
||||||
| S5/SYNC-D | портал/импорт | изменения вне sync не пишутся в `staff_sync_change_log` → терминал не узнаёт о новых/изменённых сотрудниках |
|
|
||||||
| S4/SYNC-E | `executeCommand` | проверять `status='active'` сотрудника |
|
|
||||||
| S4/SYNC-F | `executeCommand` | сохранять `org_unit_id` в `staff_attendance_events`; payload не может переопределить точку устройства |
|
|
||||||
| S3/SYNC-G | `push()` | ошибки БД одной команды → `rejected` для неё, а не 500 и откат всего пакета |
|
|
||||||
| S8/SYNC-H | `/sync/devices` | `assertCookieMutationOrigin` как в портале |
|
|
||||||
| K1/SYNC-I | `requireManagerContext` | без токенов не выдавать права (как F2) |
|
|
||||||
| S2/SYNC-J | `executeCommand` | окно времени отметки как в POST /attendance/events (−180 дней … +5 мин) |
|
|
||||||
| SYNC-K | `acknowledge()` | отклонять курсор больше `MAX(cursor_seq)` |
|
|
||||||
|
|
||||||
## 3. deploy/ (K1/PILOT-1, K2/PILOT-2) — Codex
|
|
||||||
|
|
||||||
- PILOT-1: все подстановки в `deploy/pilot/compose.yaml` → `${VAR:?VAR is required}`
|
|
||||||
(сейчас 14 без `:?`: пустой `STANDALONE_MANAGER_TOKEN` молча превращает pilot в F2).
|
|
||||||
- PILOT-2: `NODE_ENV: development` в `app`. Конфликт: `config.ts` запрещает
|
|
||||||
`STAFF_RUNTIME_MODE=standalone` при `NODE_ENV=production`. Нужно решение: либо
|
|
||||||
разрешить standalone в production при обязательных токенах и HTTPS, либо
|
|
||||||
отдельный режим для pilot. Тест требует только «не development».
|
|
||||||
- N6: `scripts/create-app-role.mjs` не работает мигратором без SUPERUSER
|
|
||||||
(«Only roles with SUPERUSER may change the SUPERUSER attribute»). CI и e2e
|
|
||||||
обходятся `scripts/test/provision-pg.mjs` (роли создаёт bootstrap-админ, как
|
|
||||||
`docker/postgres/init-app-role.sh`). В pilot мигратор = `POSTGRES_USER` =
|
|
||||||
суперпользователь; рекомендую схему как в provision-pg.mjs.
|
|
||||||
|
|
||||||
## 4. CI
|
|
||||||
|
|
||||||
- Задаче `docker-build` нужен раннер с меткой `docker-host` (Docker daemon).
|
|
||||||
Без него задача ждёт в очереди; остальные задачи не блокируются.
|
|
||||||
- `format` красная, пока не исправлен F14 (23 файла на замер 2026-09-26, в основном `src/`, `docs/`, `tests/unit/`).
|
|
||||||
- `format` станет зелёной после влития `codex/fix-format-baseline` (проверено на ветке).
|
|
||||||
- Задача `windows` выключена, пока нет раннера с меткой `windows`: включается переменной
|
|
||||||
репозитория `KRONA_WINDOWS_RUNNER=true`; для полного e2e нужен секрет
|
|
||||||
`WINDOWS_E2E_PG_ADMIN_URL` (локальная PostgreSQL 17 на раннере).
|
|
||||||
- `tests/e2e/evidence/` не хранится в git; в CI это артефакт `e2e-evidence`.
|
|
||||||
- Реальный Forgejo-раннер мной не запускался (нет API-токена) — не проверено.
|
|
||||||
|
|
||||||
## 5. N13 (гонка первого расчёта) и утечка текста ошибок БД
|
|
||||||
|
|
||||||
- `scripts/test/n13-race.mjs`, вывод `tests/e2e/evidence/n13-race.txt`: 10/25/50
|
|
||||||
параллельных первых `POST /payroll/calculate` × 5 попыток. Как есть — все 500 (N2),
|
|
||||||
гонку не видно. В одноразовой БД с обходом N2 (`DEFAULT now()` только там) — 0 из 30
|
|
||||||
некорректных попыток (без смен и с подтверждённой сменой): один `run_id`, один расчёт в БД.
|
|
||||||
- Почему не воспроизводится (вывод из кода, не проверено отдельным экспериментом):
|
|
||||||
`calculatePayroll` сначала вызывает `ensureDefaultPayRules`, а там
|
|
||||||
`INSERT ... ON CONFLICT DO UPDATE` по одним и тем же строкам `staff_pay_rules` тенанта.
|
|
||||||
Первая транзакция держит блокировки этих строк до коммита, остальные ждут — расчёты
|
|
||||||
одного тенанта выполняются по очереди случайно. Если upsert правил вынесут из расчёта
|
|
||||||
или сделают `DO NOTHING`, окно `SELECT ... FOR UPDATE` (строки нет) → `INSERT ... ON
|
|
||||||
CONFLICT DO NOTHING` со своим `runId` → строки/UPDATE по несуществующему `runId` вернётся.
|
|
||||||
Рекомендация: явный `pg_advisory_xact_lock` по (tenant, org_unit, period_start) или
|
|
||||||
`INSERT ... ON CONFLICT DO UPDATE ... RETURNING id`. Скрипт стоит перезапустить после
|
|
||||||
такого изменения.
|
|
||||||
- Ответы 500 содержат сырой текст ошибки PostgreSQL (например
|
|
||||||
`null value in column "occurred_at" of relation "staff_outbox" ...`) — клиенту видна
|
|
||||||
схема БД. Для 5xx отдавать нейтральное сообщение, подробности — только в лог.
|
|
||||||
|
|
||||||
## 6. Приёмка веток исполнителей (2026-09-26)
|
|
||||||
|
|
||||||
Подробно — `tests/e2e/REPORT.md` и `tests/e2e/report-evidence/acceptance-*.txt`.
|
|
||||||
|
|
||||||
- `codex/fix-live-database` (ea68e12, перепроверено на b289dd6): K3/N2 и K4/N1 зелёные на живой PostgreSQL 17.
|
|
||||||
Дальше по цепочке открылись K5/N4 (196800 вместо 98400 — двойное начисление) и
|
|
||||||
K6/D5 (отклонение оплаченной смены: 200, начисление осталось). A4 частично: `POST /employees`
|
|
||||||
больше не 500, но `month=2026-13` по-прежнему отдаёт `code=22008` с текстом.
|
|
||||||
- `codex/fix-format-baseline` (fd1ec3d): `prettier --check .` чистый. **Но коммит «style»
|
|
||||||
содержит функциональное исправление Antigravity**: `src/domain/sync/service.ts` и
|
|
||||||
`tests/unit/sync.test.ts` после нормализации prettier совпадают с
|
|
||||||
`antigravity/fix-reject-device-shifts` (0 строк diff) и отличаются от 331e490 на 255/139
|
|
||||||
строк. Изменение поведения стоит вынести в отдельный коммит с объяснением или влить
|
|
||||||
ветку Antigravity отдельно, иначе история смешивает темы (AGENTS.md).
|
|
||||||
- `antigravity/fix-reject-device-shifts` (795bba0, перепроверено на 1df2019 — уже на f5ac179):
|
|
||||||
K13/SYNC-A и K13/SYNC-B зелёные, остальное без изменений.
|
|
||||||
- Ни одна ветка не влита в основную (f5ac179), поэтому ID из `known-defects.json` не удалены.
|
|
||||||
- Суммы в тестах кратны рублю: расчёт округляет строку до `rounding_unit_minor`
|
|
||||||
(по умолчанию 100 коп.). Если округление должно быть другим — это решение продукта, не теста.
|
|
||||||
|
|
||||||
## 7. Круг 3 (TASK-GROK-3, 2026-09-26)
|
|
||||||
|
|
||||||
Команды и полный вывод — `tests/e2e/REPORT.md` («Круг 3») и `tests/e2e/report-evidence/round3-*.md`.
|
|
||||||
Приёмка одной командой: `node scripts/test/accept-branch.mjs <ветка>…` (нужен `E2E_PG_ADMIN_URL`
|
|
||||||
или готовые роли в `E2E_MIGRATOR_URL` + `E2E_APP_URL`).
|
|
||||||
|
|
||||||
- **Antigravity, `sync-bootstrap.test.ts` (8baad55)**: `Error: Called end on pool more than once` при
|
|
||||||
`Tests 2 passed`. Причина: `app.close()` в тесте вызывает хук `onClose` → `database.close()`
|
|
||||||
(`src/app.ts:148`), затем `afterAll` закрывает тот же `Database` ещё раз. На текущей голове
|
|
||||||
`24cc18d` файл зелёный (`1 passed (1) | 2 passed (2)`).
|
|
||||||
- **Antigravity, `sync-rejection.test.ts` (1df2019)**: `column "created_by" of relation "staff_employees"
|
|
||||||
does not exist` в `beforeAll` (`Tests 2 skipped`). На `6c5bf36`/`b283736` исправлено.
|
|
||||||
- **Конфликты**: на прежних головах `1df2019 × 8baad55` и `8baad55 × 513cdff` конфликтовали в
|
|
||||||
`src/domain/sync/service.ts`; на текущих головах (fetch 16:16 NOVT) конфликтов нет ни в одной из 55 пар.
|
|
||||||
- **Codex, `codex/fix-format-baseline`**: на `513cdff` в `service.ts`/`sync.test.ts` было 232/130 строк сверх
|
|
||||||
prettier, все есть в `antigravity/fix-reject-device-shifts`; на `a05777c` — только форматирование (19 файлов).
|
|
||||||
- **Codex, `live-postgres-remediation.test.ts`**: выполняется только при `DATABASE_TEST_ISOLATED=true`,
|
|
||||||
`DATABASE_TEST_GUARD_TOKEN`, таблице `krona_staff_test_guard` с этим маркером и имени БД
|
|
||||||
`krona_staff_audit_*`. Соглашение нигде, кроме самого теста, не описано, и ни CI, ни
|
|
||||||
`scripts/test/*` его не выполняют — в `npm run check` файл всегда `skipped`. `accept-branch` ставит
|
|
||||||
предохранитель в одноразовой БД: `1 passed (1)`. Предложение: описать соглашение в README/AGENTS
|
|
||||||
и после слияния включить его в CI (могу сделать в своей зоне, `.forgejo/workflows/ci.yml`).
|
|
||||||
- **N5 до сих пор в основе**: `tenant-isolation.test.ts` красный на `b0243d5` и всех ветках от
|
|
||||||
`f5ac179` (`permission denied for table staff_employees`); исправление — в `grok/e2e-harness`.
|
|
||||||
Локальная сборка основа + fix-live-database + fix-sync-c0-bootstrap + grok/e2e-harness:
|
|
||||||
интеграционные 7/7, храповик просит убрать ровно K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B, SYNC-C0.
|
|
||||||
- **Схема (низкий приоритет)**: `staff_notification_deliveries.outbox_id → staff_outbox(id)` — единственный
|
|
||||||
внешний ключ без `tenant_id`; все остальные составные `(tenant_id, …)`. База не мешает доставке
|
|
||||||
ссылаться на событие outbox другого арендатора (RLS фильтрует чтение, но не ссылку). Предложение:
|
|
||||||
`UNIQUE (tenant_id, id)` на `staff_outbox` и FK `(tenant_id, outbox_id)`.
|
|
||||||
- **PILOT-REHOME**: скрипта нет → `[PILOT-REHOME]` красный; контракт — C6.
|
|
||||||
|
|
@ -1,371 +0,0 @@
|
||||||
# PR: e2e-стенд на реальной PostgreSQL 17 и независимая приёмка (grok/e2e-harness)
|
|
||||||
|
|
||||||
База: `codex/bootstrap-platform-foundation` @ `f5ac179` (содержит `331e490`). Ветка перебазирована
|
|
||||||
без конфликтов. Сравнение:
|
|
||||||
<https://git.ochenstarik.ru/ochenstarik/krona-staff/compare/codex/bootstrap-platform-foundation...grok/e2e-harness>
|
|
||||||
|
|
||||||
Изменены только `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`,
|
|
||||||
`.forgejo/workflows/ci.yml`, `scripts/test/**` и одна строка в корневом `.gitignore`
|
|
||||||
(`tests/e2e/evidence/`, по требованию TASK-GROK-2 §1). `src/`, `apps/`, `migrations/`,
|
|
||||||
`deploy/`, `package.json` не тронуты. Всё, что требует правки продукта, — в
|
|
||||||
[`NOTES-FOR-EXECUTORS.md`](NOTES-FOR-EXECUTORS.md).
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
156 зелёных тестов на поддельной БД не заметили, что создание сотрудника, подтверждение
|
|
||||||
смены и расчёт зарплаты на реальной PostgreSQL падают всегда. Стенд запускает реальный сервер
|
|
||||||
под ролью приложения без привилегий на реальной PostgreSQL 17, миграции применяет мигратор без
|
|
||||||
SUPERUSER, все токены и пароли случайные (`crypto.randomBytes`). Каждый сценарий проверяет
|
|
||||||
**правильное** поведение: красный = открытый дефект.
|
|
||||||
|
|
||||||
## Что изменилось во втором круге (TASK-GROK-2)
|
|
||||||
|
|
||||||
| Пункт | Что сделано |
|
|
||||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| §1 | Запуск без `.cmd`-обёрток: `scripts/test/node-commands.mjs` — всегда `process.execPath` + JS-точка входа из `require.resolve` (`vitest.mjs`, `prettier.cjs`); сервер — один процесс `node --import tsx src/server.ts` (без дочернего процесса CLI tsx, чтобы `kill()` под Windows не оставлял сироту). Проверка на Linux — `tests/e2e/harness/node-commands.test.ts` (в `npm test`) |
|
|
||||||
| §1 | `tests/e2e/evidence/` убран из git и добавлен в `.gitignore`; в CI — артефакт `e2e-evidence`. Разовые доказательства (N5, N13, приёмка) — в `tests/e2e/report-evidence/`, скрипты туда не пишут |
|
|
||||||
| §1 | Задача `windows` в CI (включается переменной `KRONA_WINDOWS_RUNNER=true`, раннера пока нет) |
|
|
||||||
| §1 | Перебазирование на `f5ac179` — без конфликтов |
|
|
||||||
| §2 | Двойные ID `[владелец/мой]` в названиях тестов и ключах `known-defects.json` |
|
|
||||||
| §3 | Новые сценарии `[K6/D5]`, `[K7/D2]`, `[K8/D1]`, `[K9/D3]`, `[S9]`, `[A4]`, `[N12-DATE]` (`tests/e2e/50-money-and-errors.e2e.ts`), все красные и в `known-defects.json` |
|
|
||||||
| §4 | Приёмка `antigravity/fix-reject-device-shifts`, `codex/fix-format-baseline`, `codex/fix-live-database` — таблицы ниже |
|
|
||||||
| — | Синтетическая ставка 123,45 → 123,00 ₽/ч: расчёт округляет строку до рубля (`rounding_unit_minor`), тест не должен зависеть от округления (выяснилось на fix-live-database) |
|
|
||||||
|
|
||||||
## Круг 3 (TASK-GROK-3)
|
|
||||||
|
|
||||||
Ветка продолжена поверх `ba59a48` без переписывания истории (без rebase на `b0243d5`, без force-push).
|
|
||||||
|
|
||||||
| Пункт | Что сделано |
|
|
||||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| п.1 | `scripts/test/accept-branch.mjs <ветка>…`: worktree на ветку в `../krona-staff-acceptance/` + оверлей текущего стенда (`tests/e2e/**`, `scripts/test/**`), `npm run check` без переменных БД, **каждый** `tests/integration/*.test.ts` отдельно на свежей мигрированной БД PG17 (красный — по строке `Test Files`, ловит ошибки `beforeAll/afterAll`; файл, где всё пропущено, — отдельный вердикт `SKIP`), e2e и разница с основой по ID, попарный `git merge-tree --write-tree` (все ветки между собой и с основой), итог в markdown. Логи — `tests/e2e/evidence/accept/<время>/` (пароли в URL заменены на `***`) |
|
|
||||||
| п.1 | Готовые роли: если заданы `E2E_MIGRATOR_URL` и `E2E_APP_URL`, `global-setup` и `accept-branch` роли не создают и пароли не меняют (БД — из URL, сброс откатом всех миграций). По умолчанию как раньше. `provision-pg.mjs --roles keep` пересоздаёт только БД: `accept-branch` создаёт роли один раз на прогон |
|
|
||||||
| п.1 | Предохранитель изолированной БД для `live-postgres-remediation.test.ts` (Codex): в admin-режиме интеграционная БД одноразовая (`krona_staff_audit_acc_it`), `krona_staff_test_guard` со случайным маркером, `DATABASE_TEST_ISOLATED=true` — без этого файл пропускается целиком |
|
|
||||||
| п.2 | «Форматирование без поведения» (для веток с `format` в имени и `--format-check <ветка>`): версия из merge-base → prettier (конфиг основы, API из `node_modules`) → сравнение с веткой; для лишних строк — сколько из них есть в других переданных ветках |
|
|
||||||
| п.3 | Прогон по всем открытым `codex/*`, `antigravity/*` — ниже |
|
|
||||||
| п.4 | Храповик **не тронут**: `origin/codex/bootstrap-platform-foundation` = `b0243d5` (только docs поверх `f5ac179`); `git merge-base --is-ancestor origin/<ветка> origin/codex/bootstrap-platform-foundation` — `NOT merged` для fix-live-database, fix-reject-device-shifts, fix-sync-c0-bootstrap, fix-format-baseline |
|
|
||||||
| п.5 | `[PILOT-REHOME-SEED]` (зелёный) и `[PILOT-REHOME]` (красный, в `known-defects.json`) — `tests/e2e/60-pilot-rehome.e2e.ts` |
|
|
||||||
| — | `run-e2e.mjs`: проверка секретов в `evidence/` больше не падает `EISDIR` на подкаталоге `accept/` |
|
|
||||||
|
|
||||||
### п.1: ожидания владельца на SHA из задания
|
|
||||||
|
|
||||||
Команда: `node scripts/test/accept-branch.mjs 1df2019 antigravity/fix-sync-c0-bootstrap codex/fix-format-baseline`
|
|
||||||
(16:04 NOVT; `1df2019` передан SHA, потому что ветка Antigravity к этому времени была перезаписана
|
|
||||||
на `6c5bf36`). Полный вывод — [`report-evidence/round3-accept-owner-shas.md`](report-evidence/round3-accept-owner-shas.md).
|
|
||||||
|
|
||||||
| Ожидание владельца | Фактический вывод |
|
|
||||||
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| sync-rejection.test.ts на 1df2019 FAIL `column "created_by" … does not exist` | `FAIL \| 1 failed (1) \| 2 skipped (2) \| error: column "created_by" of relation "staff_employees" does not exist` |
|
|
||||||
| sync-bootstrap.test.ts на 8baad55 FAIL `Called end on pool more than once` при `Tests 2 passed` | `FAIL \| 1 failed (1) \| 2 passed (2) \| Error: Called end on pool more than once` |
|
|
||||||
| SYNC-C0 зелёный на 8baad55 | e2e «стало зелёным»: `SYNC-C0` |
|
|
||||||
| AG reject + AG c0 — конфликт в src/domain/sync/service.ts | `1df2019 \| origin/antigravity/fix-sync-c0-bootstrap \| КОНФЛИКТ \| src/domain/sync/service.ts` |
|
|
||||||
| Codex format + AG c0 — конфликт | `origin/antigravity/fix-sync-c0-bootstrap \| origin/codex/fix-format-baseline \| КОНФЛИКТ \| src/domain/sync/service.ts` |
|
|
||||||
| Codex format + AG reject — чисто | `1df2019 \| origin/codex/fix-format-baseline \| чисто` |
|
|
||||||
|
|
||||||
Все шесть совпали. На `6c5bf36` (первый прогон по именам веток,
|
|
||||||
[`round3-accept-owner-branches-first.md`](report-evidence/round3-accept-owner-branches-first.md))
|
|
||||||
`sync-rejection.test.ts` уже зелёный (`1 passed (1) | 2 passed (2)`).
|
|
||||||
|
|
||||||
### п.2: форматирование без поведения
|
|
||||||
|
|
||||||
Команда: `node scripts/test/accept-branch.mjs --format-check tmp/prettier-only-f5ac179 --format-check codex/fix-format-baseline`
|
|
||||||
([`round3-format-proof.md`](report-evidence/round3-format-proof.md)).
|
|
||||||
`tmp/prettier-only-f5ac179` (`c552d38`) — локальная синтетическая ветка: `prettier --write .` поверх
|
|
||||||
`f5ac179`, 24 файла; **не запушена**.
|
|
||||||
|
|
||||||
| Ветка | Результат |
|
|
||||||
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `tmp/prettier-only-f5ac179` (c552d38) | изменено файлов: 24 — ТОЛЬКО ФОРМАТИРОВАНИЕ |
|
|
||||||
| `codex/fix-format-baseline` (513cdff, прежняя голова) | ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER: `src/domain/sync/service.ts` 232 строки, `tests/unit/sync.test.ts` 130 строк; в `antigravity/fix-reject-device-shifts` есть 232/232 и 130/130 этих строк |
|
|
||||||
| `codex/fix-format-baseline` (a05777c, текущая, 16:09 NOVT) | изменено файлов: 19 — ТОЛЬКО ФОРМАТИРОВАНИЕ |
|
|
||||||
|
|
||||||
### п.3: все открытые ветки
|
|
||||||
|
|
||||||
Команда: `node scripts/test/accept-branch.mjs <все origin/codex/* и origin/antigravity/* кроме основы>`
|
|
||||||
после `git fetch` в 16:16 NOVT, стенд `6f408bc`. Полный вывод (интеграционные тесты по файлам, 55 пар
|
|
||||||
merge-tree) — [`report-evidence/round3-accept-all-open.md`](report-evidence/round3-accept-all-open.md).
|
|
||||||
Основа `b0243d5`: check OK, integration 3/4 (FAIL tenant-isolation), e2e 46 red / 5 green.
|
|
||||||
|
|
||||||
| Ветка | SHA | Влита в основу | check | integration | e2e red/green | Стало зелёным | Стало красным |
|
|
||||||
| ------------------------------------ | ------- | -------------------------- | ----- | --------------------------------------------------------- | ------------- | ------------------------------- | -------------------------------------------------------------------------- |
|
|
||||||
| antigravity/fix-reject-device-shifts | b283736 | нет (+1) | OK | 4/5, FAIL tenant-isolation | 44/7 | K13/SYNC-A, K13/SYNC-B | — |
|
|
||||||
| antigravity/fix-sync-c0-bootstrap | 24cc18d | нет (+3, включает b283736) | OK | 5/6, FAIL tenant-isolation | 43/8 | K13/SYNC-A, K13/SYNC-B, SYNC-C0 | — |
|
|
||||||
| codex/fix-format-baseline | a05777c | нет (+1, от f5ac179) | OK | 3/4, FAIL tenant-isolation | 46/5 | — | — |
|
|
||||||
| codex/fix-live-database | 1573860 | нет (+2, на a05777c) | OK | 4/5, FAIL tenant-isolation (live-postgres-remediation OK) | 44/7 | K3/N2, K4/N1 | — |
|
|
||||||
| antigravity/employee-notifications | 3e20544 | да (предок) | OK | 3/4 | 49/2 | — | HARNESS, PILOT-REHOME-SEED, SYNC-L (старый код: нет таблиц/маршрутов sync) |
|
|
||||||
| antigravity/offline-sync-backend | 5ee7517 | да (предок) | OK | 3/4 | 46/5 | — | — |
|
|
||||||
| codex/finance-calendar-dashboard | c08a7e6 | да (предок) | OK | 4/4 | 48/3 | A4, K4/N1 | CTRL, HARNESS, PILOT-REHOME-SEED, SYNC-L (старый код) |
|
|
||||||
| codex/frontend-library | 331e490 | да (предок) | OK | 3/4 | 46/5 | — | — |
|
|
||||||
| codex/security-finance-production | f74aa6d | да (предок) | OK | 4/4 | 49/2 | — | HARNESS, PILOT-REHOME-SEED, SYNC-L (старый код) |
|
|
||||||
| codex/server-web-production | 2f6b367 | да (предок) | OK | 4/4 | 49/2 | K4/N1 | CTRL, HARNESS, PILOT-REHOME-SEED, SYNC-L (старый код) |
|
|
||||||
|
|
||||||
- `git merge-tree`: **конфликтов нет** ни в одной из 55 пар (Antigravity выстроил цепочку reject → c0,
|
|
||||||
Codex пересобрал format без sync). Шесть веток — предки основы (уже влиты), для них «стало
|
|
||||||
красным» означает только «код старше основы».
|
|
||||||
- `tenant-isolation.test.ts` красный на основе и всех ветках от `f5ac179`
|
|
||||||
(`error: permission denied for table staff_employees`) — это N5, исправление есть в
|
|
||||||
`grok/e2e-harness` (`ba59a48`), в основу ещё не влито.
|
|
||||||
|
|
||||||
### Сборка «как будет после слияния» (локально, не запушено)
|
|
||||||
|
|
||||||
`tmp/combined-round3` = `b0243d5` + merge `codex/fix-live-database` (1573860) + merge
|
|
||||||
`antigravity/fix-sync-c0-bootstrap` (24cc18d) — без конфликтов; `tmp/combined-round3-grok` = то же +
|
|
||||||
merge `grok/e2e-harness`. Вывод — [`round3-combined-merge.md`](report-evidence/round3-combined-merge.md).
|
|
||||||
|
|
||||||
| Сборка | check | integration | e2e red/green | Стало зелёным |
|
|
||||||
| ------------------------------ | -------------------------- | -------------------------- | ------------- | --------------------------------------------- |
|
|
||||||
| combined-round3 (48128c8) | OK (199 passed, 6 skipped) | 6/7, FAIL tenant-isolation | 41/10 | K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1, SYNC-C0 |
|
|
||||||
| combined-round3-grok (e94415a) | OK (199 passed, 6 skipped) | **7/7** | 41/10 | то же |
|
|
||||||
|
|
||||||
Храповик на `combined-round3-grok`: `[run-e2e] FAIL: K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1, SYNC-C0 —
|
|
||||||
дефект исправлен, обновите known-defects.json` — ровно список из TASK-GROK-3 п.4. Удалю их отдельным
|
|
||||||
коммитом, когда ветки действительно окажутся в основе.
|
|
||||||
|
|
||||||
### п.5: PILOT-REHOME
|
|
||||||
|
|
||||||
- `[PILOT-REHOME-SEED]` — **зелёный**: 27 таблиц с `tenant_id` из `information_schema.columns`, в каждую
|
|
||||||
2+ строки заглушки `01J00000000000000000000000` и постороннего случайного арендатора (SQL мигратором,
|
|
||||||
`app.tenant_id`/`app.user_id` в транзакции); таблица без сида → красный со списком.
|
|
||||||
- `[PILOT-REHOME]` — **красный** (в `known-defects.json`): `scripts/rehome-tenant.mjs отсутствует
|
|
||||||
(TASK-CODEX-2 п.2.2): … Засеяно таблиц: 27, ledger SUM(amount_minor)=…`.
|
|
||||||
- Сценарий проверен временным эталоном скрипта (не коммитился, удалён): с ним зелёный; мутации
|
|
||||||
«без записи аудита», «без отказа на повтор», «пропущена таблица», «dry-run пишет» — красные
|
|
||||||
([`report-evidence/round3-pilot-rehome.txt`](report-evidence/round3-pilot-rehome.txt)).
|
|
||||||
- Контракт, который проверяет сценарий, — `NOTES-FOR-EXECUTORS.md`, C6.
|
|
||||||
|
|
||||||
## Как запустить
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export E2E_PG_ADMIN_URL=postgres://<admin>:<pw>@127.0.0.1:55433/postgres # только создание ролей и БД
|
|
||||||
E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # храповик known-defects (как в CI)
|
|
||||||
node scripts/test/run-e2e.mjs --no-known-defects # строго: любой красный = exit 1
|
|
||||||
./scripts/test/ci-local.sh # локальный эквивалент verify/format/e2e (bash)
|
|
||||||
node scripts/test/accept-branch.mjs <ветка> [<ветка>…] # приёмка веток (worktree, check, integration, e2e, merge-tree)
|
|
||||||
# роли уже есть (общий кластер): E2E_MIGRATOR_URL=… E2E_APP_URL=… — роли и пароли не трогаются
|
|
||||||
```
|
|
||||||
|
|
||||||
## Таблица по ID (основная ветка f5ac179 + этот стенд)
|
|
||||||
|
|
||||||
Команда: `./scripts/test/ci-local.sh` (внутри `E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs`),
|
|
||||||
2026-09-26 ~16:25 (UTC+7), PostgreSQL 17.11, стенд круга 3:
|
|
||||||
|
|
||||||
```text
|
|
||||||
::: [check] with_db_env npm run check
|
|
||||||
Test Files 20 passed (20)
|
|
||||||
Tests 196 passed (196)
|
|
||||||
::: [format-check] FAIL exit=1 # 23 файла базовой линии основы, ни одного из tests/e2e, scripts/test, .forgejo
|
|
||||||
Test Files 6 failed | 1 passed (7)
|
|
||||||
Tests 46 failed | 10 passed (56)
|
|
||||||
[run-e2e] OK: стенд работает, все красные сценарии — известные открытые дефекты
|
|
||||||
```
|
|
||||||
|
|
||||||
Круг 3 добавил `[PILOT-REHOME-SEED]` (GREEN) и `[PILOT-REHOME]` (RED, `scripts/rehome-tenant.mjs
|
|
||||||
отсутствует (TASK-CODEX-2 п.2.2)`); остальные строки таблицы — без изменений со второго круга.
|
|
||||||
|
|
||||||
| ID владельца / мой | Цвет | Фактический вывод (первая строка assert) |
|
|
||||||
| ---------------------------- | ----- | ------------------------------------------------------------------------------------------------- |
|
|
||||||
| HARNESS (×5), CTRL (×2) | GREEN | 7/7 passed — стенд и позитивные контроли |
|
|
||||||
| K1/F2 | RED | `{ portal: 200, payroll: 200, write: 201 }` вместо 401 |
|
|
||||||
| K1/SYNC-I | RED | анонимная регистрация устройства `HTTP 201` |
|
|
||||||
| K1/PILOT-1 | RED | 14 подстановок без `:?` (первая `STANDALONE_MANAGER_TOKEN`) |
|
|
||||||
| K2/PILOT-2 | RED | `NODE_ENV=development` у сервиса `app` |
|
|
||||||
| K3/N2 | RED | подтверждение смены `HTTP 500 code 23502 null value in column "occurred_at" of "staff_outbox"` |
|
|
||||||
| K4/N1 | RED | `HTTP 500 code 42P08 inconsistent types deduced for parameter $2` |
|
|
||||||
| K5/N4 | RED | блокирован K3 (500 на подтверждении) |
|
|
||||||
| K6/D5 | RED | блокирован K3 (500 на подтверждении) |
|
|
||||||
| K7/D2 | RED | `afterFirst: 22200, afterSecond: 22200` вместо `33300, 37700` |
|
|
||||||
| K8/D1 | RED | блокирован K3 (500 на подтверждении) |
|
|
||||||
| K9/D3 | RED | `params.shift_rate_minor` не сохранён (`HTTP 200`, в `params` нет ключа) |
|
|
||||||
| K10/F3 | RED | `rawTokenInCookie: true` |
|
|
||||||
| K11/F4 | RED | все попытки `401`, 429 нет |
|
|
||||||
| K12/N3 | RED | подписка на `https://127.0.0.1:<port>` `HTTP 201`, 1 входящее соединение |
|
|
||||||
| K13/SYNC-A, K13/SYNC-B | RED | `upsert_work_session` / `upsert_timesheet_entry` → `status: applied` |
|
|
||||||
| K17/OWN-10, K17/OWN-11 | RED | `404 Route GET .../terminal/employees/:id/enrollment` / `PUT .../biometric-consent` (нет API, C3) |
|
|
||||||
| K19/OWN-07 | RED | `404 Route POST /access-grants` (нет API, C1/C2) |
|
|
||||||
| D6/OWN-01, D6/OWN-02 | RED | `404 Route POST /access-grants` (нет API, C1) |
|
|
||||||
| OWN-03..06, OWN-08, OWN-09 | RED | `404 Route POST /access-grants` (нет API, C1/C2) |
|
|
||||||
| S2/SYNC-J | RED | отметка 2001 года → `status: applied` |
|
|
||||||
| S3/SYNC-G | RED | `HTTP 500 code 22007` на весь пакет |
|
|
||||||
| S4/SYNC-E | RED | отметка уволенного → `applied` |
|
|
||||||
| S4/SYNC-F | RED | `{ ownOrgUnit: null, foreign: "applied" }` |
|
|
||||||
| S5/SYNC-C | RED | `HTTP 500 code 42703 column "status" does not exist` (блокирован SYNC-C0) |
|
|
||||||
| S5/SYNC-D | RED | изменение из портала не видно в инкрементальном pull |
|
|
||||||
| S8/SYNC-H | RED | регистрация устройства по cookie с чужим Origin `HTTP 201` |
|
|
||||||
| S9 | RED | удаление точки с устройством: `23502 null value in column "tenant_id"` |
|
|
||||||
| A4 | RED | 2 тела 500 с SQLSTATE/текстом PostgreSQL (`POST /employees` 42P08, `month=2026-13` 22008) |
|
|
||||||
| N9 | RED | чужой push-endpoint перепривязан `HTTP 201` |
|
|
||||||
| N10 | RED | терминал: `{ professions: 200, units: 200 }` |
|
|
||||||
| N11 | RED | импорт с неверным kind `HTTP 500 code 23514` |
|
|
||||||
| N12 | RED | лишнее поле и нереальная сумма → `HTTP 200` |
|
|
||||||
| N12-DATE | RED | `month=2026-13` → `HTTP 500 code 22008`; `2026-02-30` уже 400 |
|
|
||||||
| F7 | RED | повтор Idempotency-Key создал второе событие |
|
|
||||||
| F10 | RED | мутация по cookie без Origin `HTTP 201` |
|
|
||||||
| SYNC-C0 (нет ID у владельца) | RED | bootstrap pull `HTTP 500 code 42703 column "status" does not exist` |
|
|
||||||
| SYNC-K (нет ID у владельца) | RED | ack курсора из будущего `HTTP 200` |
|
|
||||||
| SYNC-L | GREEN | отозванное устройство получает 403 (позитивный контроль) |
|
|
||||||
| PILOT-REHOME-SEED | GREEN | 27 таблиц с `tenant_id` засеяны (заглушка + посторонний арендатор) |
|
|
||||||
| PILOT-REHOME | RED | `scripts/rehome-tenant.mjs отсутствует (TASK-CODEX-2 п.2.2)` |
|
|
||||||
| PILOT-3 | GREEN | у `app` нет учётных данных мигратора |
|
|
||||||
| N5 (tenant-isolation) | GREEN | в `npm run check` с тестовой БД: `Test Files 20 passed (20)`, `Tests 196 passed (196)` |
|
|
||||||
| N13 (скрипт, не vitest) | — | не воспроизведено: 0 из 30 попыток при 10/25/50 (обход K3 в одноразовой БД), см. ниже |
|
|
||||||
|
|
||||||
Сверх таблицы TASK-GROK-2 §2 (по тексту AUDIT.md): K1/SYNC-I (анонимный доступ без токенов на
|
|
||||||
`/sync`), K1/PILOT-1 (подстановки без `:?` названы в K1), K2/PILOT-2 (development в пилоте — суть K2).
|
|
||||||
|
|
||||||
## Приёмка веток исполнителей (TASK-GROK-2 §4)
|
|
||||||
|
|
||||||
Способ: `git worktree add --detach <dir> origin/<ветка>`, поверх — `tests/e2e/` и `scripts/test/`
|
|
||||||
из этой ветки (в ветки исполнителей ничего не коммитилось), затем
|
|
||||||
`E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs`.
|
|
||||||
«Было» — основная ветка `f5ac179` с тем же стендом (45 red / 9 green, храповик OK). Полные таблицы —
|
|
||||||
`tests/e2e/report-evidence/acceptance-*.txt`. Ни одна ветка не влита в основную
|
|
||||||
(`git merge-base --is-ancestor` → нет), поэтому `known-defects.json` не сокращён.
|
|
||||||
|
|
||||||
### `codex/fix-live-database` @ ea68e12, перепроверено на b289dd6
|
|
||||||
|
|
||||||
```text
|
|
||||||
== roundtrip OK: rolledBack=11 reapplied=11
|
|
||||||
Tests 41 failed | 13 passed (54)
|
|
||||||
| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K3/N2 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K4/N1 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
```
|
|
||||||
|
|
||||||
| ID | Было | Стало | Доказательство |
|
|
||||||
| ---------- | ---- | ----- | ------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| K3/N2 | RED | GREEN | было: подтверждение смены `500 23502 occurred_at`; стало: passed (8 ч × 123,00 ₽ = 98400 в ledger) |
|
|
||||||
| K4/N1 | RED | GREEN | было: `500 42P08`; стало: passed (201, сотрудник в БД) |
|
|
||||||
| K13/SYNC-A | RED | GREEN | было: `upsert_work_session → applied`; стало: passed (см. находку по fd1ec3d) |
|
|
||||||
| K13/SYNC-B | RED | GREEN | было: `upsert_timesheet_entry → applied`; стало: passed |
|
|
||||||
| K5/N4 | RED | RED | было: блокирован K3; стало: `смена должна быть начислена ровно один раз: expected 196800n to be 98400n` |
|
|
||||||
| K6/D5 | RED | RED | было: блокирован K3; стало: `после отклонения оплаченной смены должно быть сторно: expected 98400n to be 0n` |
|
|
||||||
| K8/D1 | RED | RED | было: блокирован K3; стало: `C4 verify: HTTP 404 Route GET .../payroll/runs/:id/verify not found` |
|
|
||||||
| A4 | RED | RED | было: 2 утечки; стало: 1 — `POST /payroll/calculate month=2026-13` → `code=22008` |
|
|
||||||
| F14 | RED | GREEN | `prettier --check .` → `All matched files use Prettier code style!` (без оверлея evidence) |
|
|
||||||
|
|
||||||
Ветку обновили force-push во время приёмки (`ea68e12` → `b289dd6`, изменён только
|
|
||||||
`tests/integration/live-postgres-remediation.test.ts`); повторный прогон на `b289dd6` дал те же
|
|
||||||
41 red / 13 green и ту же таблицу.
|
|
||||||
|
|
||||||
Вердикт: K3 и K4 исправлены (подтверждено на живой PostgreSQL 17, миграции roundtrip). После влития
|
|
||||||
убрать `K3/N2`, `K4/N1` из `known-defects.json`. Цепочка расчёта теперь доходит до денежных дефектов:
|
|
||||||
K5 и K6 подтверждены по существу.
|
|
||||||
|
|
||||||
### `codex/fix-format-baseline` @ fd1ec3d
|
|
||||||
|
|
||||||
```text
|
|
||||||
Tests 43 failed | 11 passed (54)
|
|
||||||
| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
$ node node_modules/prettier/bin/prettier.cjs --check .
|
|
||||||
All matched files use Prettier code style!
|
|
||||||
```
|
|
||||||
|
|
||||||
| ID | Было | Стало | Доказательство |
|
|
||||||
| ---------- | -------------- | ----- | ------------------------------------------------- |
|
|
||||||
| F14 | RED (23 файла) | GREEN | `All matched files use Prettier code style!` |
|
|
||||||
| K13/SYNC-A | RED | GREEN | было: `applied`; стало: passed |
|
|
||||||
| K13/SYNC-B | RED | GREEN | было: `applied`; стало: passed |
|
|
||||||
| остальные | без изменений | — | compare по `e2e-results.json`: других отличий нет |
|
|
||||||
|
|
||||||
**Находка:** «style»-коммит `fd1ec3d` содержит функциональное исправление Antigravity.
|
|
||||||
После нормализации prettier `src/domain/sync/service.ts` отличается от `331e490` на 255 строк diff
|
|
||||||
и совпадает с `antigravity/fix-reject-device-shifts` (0 строк); `tests/unit/sync.test.ts` — 139 и 0.
|
|
||||||
Изменение поведения скрыто в коммите форматирования — смешение тем (AGENTS.md).
|
|
||||||
|
|
||||||
### `antigravity/fix-reject-device-shifts` @ 795bba0, перепроверено на 1df2019
|
|
||||||
|
|
||||||
```text
|
|
||||||
Tests 43 failed | 11 passed (54)
|
|
||||||
| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
```
|
|
||||||
|
|
||||||
| ID | Было | Стало | Доказательство |
|
|
||||||
| ---------- | ---- | ----- | ------------------------------------------------------- |
|
|
||||||
| K13/SYNC-A | RED | GREEN | было: `upsert_work_session → applied`; стало: passed |
|
|
||||||
| K13/SYNC-B | RED | GREEN | было: `upsert_timesheet_entry → applied`; стало: passed |
|
|
||||||
| остальные | — | — | других отличий нет |
|
|
||||||
|
|
||||||
Ветку перебазировали на `f5ac179` во время приёмки (`795bba0` → `1df2019`); повторный прогон
|
|
||||||
на `1df2019`: те же 43 red / 11 green, отличия только K13/SYNC-A/B.
|
|
||||||
|
|
||||||
Вердикт: K13 исправлен. Убрать `K13/SYNC-A`,
|
|
||||||
`K13/SYNC-B` отдельным коммитом после влития (и после исправления очистки в
|
|
||||||
`sync-rejection.test.ts`, TASK-GROK-2 §4.1).
|
|
||||||
|
|
||||||
## Windows
|
|
||||||
|
|
||||||
- Запуск стенда переписан без `npx` и `node_modules/.bin` (раньше `spawn('npx')` и
|
|
||||||
`spawn('.../.bin/tsx')` падали на Windows с `ENOENT`). Проверка на Linux:
|
|
||||||
`tests/e2e/harness/node-commands.test.ts` — 8 тестов: команды начинаются с `process.execPath`,
|
|
||||||
без `.cmd`/`.bin`/`npx`, файлы существуют; под `win32` дочерний процесс получает
|
|
||||||
`SystemRoot`/`TEMP`, секреты родителя не наследуются.
|
|
||||||
- **Реальный запуск на Windows 11 — не проверено**: Windows-раннера у git.ochenstarik.ru нет.
|
|
||||||
В CI добавлена задача `windows` (`runs-on: windows`), включаемая переменной
|
|
||||||
`KRONA_WINDOWS_RUNNER=true`; для полного e2e нужен секрет `WINDOWS_E2E_PG_ADMIN_URL`.
|
|
||||||
- `scripts/test/ci-local.sh` — bash, на Windows не предназначен (под Windows запускать
|
|
||||||
`node scripts/test/run-e2e.mjs`).
|
|
||||||
|
|
||||||
## Evidence вне git
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # (повторный прогон)
|
|
||||||
[run-e2e] OK: стенд работает, все красные сценарии — известные открытые дефекты
|
|
||||||
$ git status --porcelain
|
|
||||||
# (пусто)
|
|
||||||
```
|
|
||||||
|
|
||||||
## N5 (tenant-isolation)
|
|
||||||
|
|
||||||
До (`778ba2d`, очистка ролью приложения) — [`report-evidence/n5-before.txt`](report-evidence/n5-before.txt):
|
|
||||||
`error: permission denied for table staff_employees`, `exit=1`. После (очистка ролью мигратора с
|
|
||||||
`app.tenant_id`) — [`report-evidence/n5-after.txt`](report-evidence/n5-after.txt): 2 прогона, `exit=0`.
|
|
||||||
На `f5ac179`: `npm run check` с тестовой БД — `Test Files 20 passed (20)`, `Tests 196 passed (196)`.
|
|
||||||
|
|
||||||
## CI (`.forgejo/workflows/ci.yml`)
|
|
||||||
|
|
||||||
- `verify`: `postgres:17-alpine@sha256:…`, роли как в pilot (мигратор NOSUPERUSER, app NOBYPASSRLS),
|
|
||||||
миграции `up → откат всех 11 → up → up`, `npm run check` с тестовой БД, `npm audit`.
|
|
||||||
- `format`: отдельно (F14 красный до влития `codex/fix-format-baseline`).
|
|
||||||
- `e2e`: PostgreSQL 17, `E2E_MIGRATIONS=roundtrip`, храповик; evidence — артефакт `e2e-evidence`
|
|
||||||
(`actions/upload-artifact@ea165f8… # v4.6.2`, Forgejo 9.0.3).
|
|
||||||
- `windows`: выключена без раннера (см. выше). `docker-build`: метка `docker-host`, `needs: verify`, только push.
|
|
||||||
- Все actions закреплены SHA (F13), SHA совпадают на github.com и code.forgejo.org.
|
|
||||||
|
|
||||||
Механизм красных сценариев — храповик `known-defects.json`, а не «allowed to fail»: сломанный стенд,
|
|
||||||
новый красный ID или исправленный, но не вычеркнутый ID делают задачу `e2e` красной.
|
|
||||||
|
|
||||||
Валидация: `actionlint -config-file actionlint.yaml .forgejo/workflows/ci.yml` (1.7.12, метки
|
|
||||||
`docker`, `docker-host`, `windows`) → 0 замечаний. Локально `./scripts/test/ci-local.sh`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
::: [migrate-roundtrip] OK (== roundtrip OK: rolledBack=11 reapplied=11)
|
|
||||||
::: [check] OK (Test Files 20 passed (20), Tests 196 passed (196))
|
|
||||||
::: [audit] OK (found 0 vulnerabilities)
|
|
||||||
::: [format-check] FAIL (Code style issues found in 23 files — F14)
|
|
||||||
::: [e2e] OK (Tests 45 failed | 9 passed (54), храповик OK)
|
|
||||||
```
|
|
||||||
|
|
||||||
## N13 (гонка первого расчёта)
|
|
||||||
|
|
||||||
`node scripts/test/n13-race.mjs` (10/25/50 параллельных первых расчётов × 5 попыток) —
|
|
||||||
[`report-evidence/n13-race.txt`](report-evidence/n13-race.txt): как есть 15/15 → 500 (K3); с обходом K3
|
|
||||||
в одноразовой БД — 0 некорректных из 30 (с подтверждённой сменой и без). Не воспроизведено, как и у
|
|
||||||
владельца; вероятная причина (гипотеза по коду) — upsert `ensureDefaultPayRules` сериализует расчёты
|
|
||||||
тенанта. Прогон до перебазирования, на `778ba2d`.
|
|
||||||
|
|
||||||
## Не проверено
|
|
||||||
|
|
||||||
- Реальный Forgejo-раннер (workflow выполнялся только локальным эквивалентом), `docker build`.
|
|
||||||
- Запуск на Windows 11 (раннера нет); Windows-only: DPAPI, ACL каталога данных, установщик, WinUI,
|
|
||||||
named pipe — см. тесты Codex в `apps/KronaStaffTerminal.Tests`.
|
|
||||||
- OWN-01..11, K8 (C4) и K9 (C5) проверяют предполагаемые контракты из NOTES.
|
|
||||||
- K5/N4, K6/D5, K8/D1 на основной ветке блокированы K3; по существу проверены только на
|
|
||||||
`codex/fix-live-database`.
|
|
||||||
- S9 проверен удалением SQL-запросом мигратора (публичного API удаления точки нет).
|
|
||||||
- Причина невоспроизводимости N13 — гипотеза по коду.
|
|
||||||
- Круг 3: `scripts/rehome-tenant.mjs` не существует — `[PILOT-REHOME]` проверен только временным
|
|
||||||
эталоном; контракт C6 — предположение. `accept-branch` в CI не запускается (разовый инструмент
|
|
||||||
владельца/приёмщика). Режим `E2E_MIGRATOR_URL/E2E_APP_URL` проверен на том же локальном кластере
|
|
||||||
(роли созданы заранее `provision-pg`), на чужом кластере — не проверено.
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
// HTTP-клиент e2e. Тело ответа типизировано как `any` намеренно: тесты проверяют
|
|
||||||
// внешний контракт JSON, а не типы src/. Правила no-unsafe-* отключены только здесь
|
|
||||||
// и в файлах сценариев.
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
|
|
||||||
import pg from 'pg';
|
|
||||||
import { inject } from 'vitest';
|
|
||||||
import type { E2EContext } from './context.js';
|
|
||||||
|
|
||||||
export interface ApiResponse {
|
|
||||||
status: number;
|
|
||||||
body: any;
|
|
||||||
text: string;
|
|
||||||
headers: Headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestOptions {
|
|
||||||
token?: string | undefined;
|
|
||||||
body?: unknown;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
base?: 'secured' | 'open' | 'ratelimit';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ctx(): E2EContext {
|
|
||||||
return inject('e2e');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function api(
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
options: RequestOptions = {},
|
|
||||||
): Promise<ApiResponse> {
|
|
||||||
const context = ctx();
|
|
||||||
const base =
|
|
||||||
options.base === 'open'
|
|
||||||
? context.openBaseUrl
|
|
||||||
: options.base === 'ratelimit'
|
|
||||||
? context.rateLimitBaseUrl
|
|
||||||
: context.baseUrl;
|
|
||||||
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
|
||||||
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
||||||
let payload: string | undefined;
|
|
||||||
if (options.body !== undefined) {
|
|
||||||
headers['content-type'] = 'application/json';
|
|
||||||
payload = JSON.stringify(options.body);
|
|
||||||
}
|
|
||||||
const url = path.startsWith('/api/') || path.startsWith('/staff') ? path : `/api/v1/staff${path}`;
|
|
||||||
const response = await fetch(`${base}${url}`, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
...(payload === undefined ? {} : { body: payload }),
|
|
||||||
redirect: 'manual',
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
let body: any = null;
|
|
||||||
try {
|
|
||||||
body = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
body = null;
|
|
||||||
}
|
|
||||||
return { status: response.status, body, text, headers: response.headers };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Краткое описание ответа для сообщений assert (без токенов). */
|
|
||||||
export function describeResponse(response: ApiResponse): string {
|
|
||||||
const snippet = response.text.length > 300 ? `${response.text.slice(0, 300)}…` : response.text;
|
|
||||||
return `HTTP ${response.status} ${snippet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Запрос к БД ролью приложения внутри tenant-транзакции (RLS действует). */
|
|
||||||
export async function dbQuery<T extends pg.QueryResultRow = Record<string, unknown>>(
|
|
||||||
sql: string,
|
|
||||||
params: unknown[] = [],
|
|
||||||
): Promise<T[]> {
|
|
||||||
return dbQueryAs<T>(ctx().tenantId, sql, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Запрос ролью приложения (krona_staff_app) под указанным арендатором (app.tenant_id). */
|
|
||||||
export async function dbQueryAs<T extends pg.QueryResultRow = Record<string, unknown>>(
|
|
||||||
tenantId: string,
|
|
||||||
sql: string,
|
|
||||||
params: unknown[] = [],
|
|
||||||
): Promise<T[]> {
|
|
||||||
const client = new pg.Client({ connectionString: ctx().appDatabaseUrl });
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
|
|
||||||
const result = await client.query<T>(sql, params);
|
|
||||||
await client.query('COMMIT');
|
|
||||||
return result.rows;
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
export interface E2EContext {
|
|
||||||
baseUrl: string;
|
|
||||||
openBaseUrl: string;
|
|
||||||
rateLimitBaseUrl: string;
|
|
||||||
managerToken: string;
|
|
||||||
terminalToken: string;
|
|
||||||
tenantId: string;
|
|
||||||
actorId: string;
|
|
||||||
terminalActorId: string;
|
|
||||||
appDatabaseUrl: string;
|
|
||||||
migrationDatabaseUrl: string;
|
|
||||||
/** Админ (владелец кластера) в БД стенда; null, если не передан. Только для PILOT-REHOME. */
|
|
||||||
adminDatabaseUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'vitest' {
|
|
||||||
export interface ProvidedContext {
|
|
||||||
e2e: E2EContext;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const crockford = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
||||||
|
|
||||||
/** Случайный ULID (время + crypto.randomBytes), чтобы не зависеть от src/. */
|
|
||||||
export function randomUlid(): string {
|
|
||||||
let time = Date.now();
|
|
||||||
let timePart = '';
|
|
||||||
for (let i = 0; i < 10; i += 1) {
|
|
||||||
timePart = crockford[time % 32]! + timePart;
|
|
||||||
time = Math.floor(time / 32);
|
|
||||||
}
|
|
||||||
const bytes = randomBytes(16);
|
|
||||||
let randomPart = '';
|
|
||||||
for (let i = 0; i < 16; i += 1) randomPart += crockford[bytes[i]! % 32]!;
|
|
||||||
return timePart + randomPart;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Синтетическая метка для данных тестов: очевидно не реальные люди/суммы. */
|
|
||||||
export function synthetic(label: string): string {
|
|
||||||
return `E2E-${label}-${randomBytes(3).toString('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
// Подготовка данных через публичный API. Везде только синтетические данные.
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
||||||
import { expect } from 'vitest';
|
|
||||||
import { api, ctx, dbQuery, describeResponse } from './client.js';
|
|
||||||
import { randomUlid, synthetic } from './context.js';
|
|
||||||
|
|
||||||
export interface OrgUnit {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Employee {
|
|
||||||
id: string;
|
|
||||||
external_ref: string;
|
|
||||||
personnel_number: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Создаёт торговые точки через импорт (единственный публичный способ в текущем API). */
|
|
||||||
export async function createOrgUnits(count: number): Promise<OrgUnit[]> {
|
|
||||||
const units = Array.from({ length: count }, () => {
|
|
||||||
const code = `e2e-${randomUlid().slice(-10).toLowerCase()}`;
|
|
||||||
return { code, name: synthetic('Точка') };
|
|
||||||
});
|
|
||||||
const response = await api('POST', '/imports/owner-workbook', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { source_name: synthetic('import-units'), units },
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBeLessThan(300);
|
|
||||||
const rows = await dbQuery<{ id: string; code: string }>(
|
|
||||||
'SELECT id, code FROM staff_org_units WHERE code = ANY($1::text[])',
|
|
||||||
[units.map((unit) => unit.code)],
|
|
||||||
);
|
|
||||||
return units.map((unit) => ({
|
|
||||||
code: unit.code,
|
|
||||||
id: rows.find((row) => row.code === unit.code)!.id,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создаёт сотрудников через импорт книги владельца: POST /employees на реальной БД
|
|
||||||
* сейчас падает (N1), а сценарии, не относящиеся к N1, не должны зависеть от этого дефекта.
|
|
||||||
*/
|
|
||||||
export async function importEmployees(count: number): Promise<Employee[]> {
|
|
||||||
const employees = Array.from({ length: count }, () => {
|
|
||||||
const suffix = randomUlid().slice(-8);
|
|
||||||
return {
|
|
||||||
external_ref: `e2e:${suffix}`,
|
|
||||||
personnel_number: `E2E-${suffix}`,
|
|
||||||
display_name: synthetic('Сотрудник'),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const response = await api('POST', '/imports/owner-workbook', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { source_name: synthetic('import-employees'), employees },
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBeLessThan(300);
|
|
||||||
const rows = await dbQuery<{ id: string; external_ref: string }>(
|
|
||||||
'SELECT id, external_ref FROM staff_employees WHERE external_ref = ANY($1::text[])',
|
|
||||||
[employees.map((employee) => employee.external_ref)],
|
|
||||||
);
|
|
||||||
return employees.map((employee) => ({
|
|
||||||
...employee,
|
|
||||||
id: rows.find((row) => row.external_ref === employee.external_ref)!.id,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function issueEmployeeToken(employeeId: string): Promise<string> {
|
|
||||||
const response = await api('POST', `/employees/${employeeId}/access-token`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(200);
|
|
||||||
return response.body.access_token as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clock(
|
|
||||||
employeeId: string,
|
|
||||||
eventType: 'check_in' | 'check_out',
|
|
||||||
occurredAt: Date,
|
|
||||||
orgUnitId?: string,
|
|
||||||
) {
|
|
||||||
return api('POST', '/attendance/events', {
|
|
||||||
token: ctx().terminalToken,
|
|
||||||
body: {
|
|
||||||
employee_id: employeeId,
|
|
||||||
event_type: eventType,
|
|
||||||
occurred_at: occurredAt.toISOString(),
|
|
||||||
source_event_id: `e2e-${randomUlid()}`,
|
|
||||||
...(orgUnitId ? { org_unit_id: orgUnitId } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Дата (UTC) N дней назад в полдень: всегда в окне терминала (<=180 дней) и не в будущем. */
|
|
||||||
export function daysAgo(days: number, hourUtc = 9): Date {
|
|
||||||
const date = new Date();
|
|
||||||
date.setUTCDate(date.getUTCDate() - days);
|
|
||||||
date.setUTCHours(hourUtc, 0, 0, 0);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isoDate(date: Date): string {
|
|
||||||
return date.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Отработанная смена: check_in/check_out терминалом + сверка; возвращает id смены. */
|
|
||||||
export async function workedShift(
|
|
||||||
employeeId: string,
|
|
||||||
orgUnitId: string,
|
|
||||||
day: Date,
|
|
||||||
hours = 8,
|
|
||||||
): Promise<string> {
|
|
||||||
const start = new Date(day);
|
|
||||||
const end = new Date(start.getTime() + hours * 3_600_000);
|
|
||||||
const inResponse = await clock(employeeId, 'check_in', start, orgUnitId);
|
|
||||||
expect(inResponse.status, describeResponse(inResponse)).toBe(201);
|
|
||||||
const outResponse = await clock(employeeId, 'check_out', end, orgUnitId);
|
|
||||||
expect(outResponse.status, describeResponse(outResponse)).toBe(201);
|
|
||||||
const reconcile = await api('POST', '/work-sessions/reconcile', {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: { date_from: isoDate(start), date_to: isoDate(start) },
|
|
||||||
});
|
|
||||||
expect(reconcile.status, describeResponse(reconcile)).toBe(200);
|
|
||||||
const rows = await dbQuery<{ id: string }>(
|
|
||||||
`SELECT id FROM staff_work_sessions
|
|
||||||
WHERE employee_id = $1 AND org_unit_id = $2 AND business_date = $3::date`,
|
|
||||||
[employeeId, orgUnitId, isoDate(start)],
|
|
||||||
);
|
|
||||||
expect(rows.length, 'сверка должна создать смену').toBe(1);
|
|
||||||
return rows[0]!.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hourlyRuleId(): Promise<string> {
|
|
||||||
const response = await api('GET', '/pay-rules', { token: ctx().managerToken });
|
|
||||||
expect(response.status, describeResponse(response)).toBe(200);
|
|
||||||
const rules = response.body.pay_rules as Array<{ id: string; code: string }>;
|
|
||||||
const hourly = rules.find((rule) => rule.code === 'hourly') ?? rules[0];
|
|
||||||
expect(hourly, 'должно существовать хотя бы одно правило оплаты').toBeTruthy();
|
|
||||||
return hourly!.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Синтетическая ставка 123,00 ₽/ч (заведомо не реальная сумма). Кратна рублю: расчёт
|
|
||||||
* округляет строку до rounding_unit_minor (по умолчанию 100 коп.), и 8 ч × ставка
|
|
||||||
* должны совпадать с начислением без учёта округления.
|
|
||||||
*/
|
|
||||||
export const SYNTHETIC_RATE_MINOR = 12_300;
|
|
||||||
|
|
||||||
export async function assignHourly(employeeId: string, orgUnitId: string | null) {
|
|
||||||
const response = await api('PUT', `/employees/${employeeId}/pay-assignment`, {
|
|
||||||
token: ctx().managerToken,
|
|
||||||
body: {
|
|
||||||
pay_rule_id: await hourlyRuleId(),
|
|
||||||
org_unit_id: orgUnitId,
|
|
||||||
rate_minor: SYNTHETIC_RATE_MINOR,
|
|
||||||
effective_from: '2026-01-01',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.status, describeResponse(response)).toBe(200);
|
|
||||||
}
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
// Глобальная подготовка e2e: реальная PostgreSQL 17, миграции ролью мигратора
|
|
||||||
// (без SUPERUSER), реальный процесс сервера под ролью приложения, случайные секреты.
|
|
||||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import { createWriteStream, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
||||||
import { createServer } from 'node:net';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { TestProject } from 'vitest/node';
|
|
||||||
import { baseEnv, nodeWithTsx } from '../../../scripts/test/node-commands.mjs';
|
|
||||||
import { randomUlid, type E2EContext } from './context.js';
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(import.meta.dirname, '../../..');
|
|
||||||
const evidenceDir = path.join(repoRoot, 'tests/e2e/evidence');
|
|
||||||
|
|
||||||
function parseEnvFile(file: string): Record<string, string> {
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
||||||
const index = line.indexOf('=');
|
|
||||||
if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function freePort(): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = createServer();
|
|
||||||
server.once('error', reject);
|
|
||||||
server.listen(0, '127.0.0.1', () => {
|
|
||||||
const address = server.address();
|
|
||||||
const port = typeof address === 'object' && address ? address.port : 0;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function runNode(args: string[], env: NodeJS.ProcessEnv, label: string) {
|
|
||||||
const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, encoding: 'utf8' });
|
|
||||||
const output = `${result.stdout}${result.stderr}`;
|
|
||||||
console.log(`[e2e] ${label}:\n${output.trim()}`);
|
|
||||||
if (result.status !== 0) throw new Error(`${label} failed with exit ${result.status}`);
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function vapidEnv(): Record<string, string> {
|
|
||||||
const output = spawnSync(process.execPath, ['scripts/generate-vapid-keys.mjs'], {
|
|
||||||
cwd: repoRoot,
|
|
||||||
encoding: 'utf8',
|
|
||||||
}).stdout;
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const line of output.split('\n')) {
|
|
||||||
const index = line.indexOf('=');
|
|
||||||
if (index > 0) env[line.slice(0, index)] = line.slice(index + 1);
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StartedServer {
|
|
||||||
child: ChildProcess;
|
|
||||||
baseUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startServer(name: string, env: Record<string, string>): Promise<StartedServer> {
|
|
||||||
const port = await freePort();
|
|
||||||
const logPath = path.join(evidenceDir, `server-${name}.txt`);
|
|
||||||
const log = createWriteStream(logPath, { flags: 'w' });
|
|
||||||
const server = nodeWithTsx(['src/server.ts']);
|
|
||||||
const child = spawn(server.command, server.args, {
|
|
||||||
cwd: repoRoot,
|
|
||||||
env: {
|
|
||||||
...baseEnv(),
|
|
||||||
NODE_ENV: 'test',
|
|
||||||
HOST: '127.0.0.1',
|
|
||||||
PORT: String(port),
|
|
||||||
LOG_LEVEL: 'info',
|
|
||||||
STAFF_RUNTIME_MODE: 'standalone',
|
|
||||||
...env,
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
child.stdout?.pipe(log);
|
|
||||||
child.stderr?.pipe(log);
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
const deadline = Date.now() + 60_000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (child.exitCode !== null) {
|
|
||||||
throw new Error(`server ${name} exited early with ${child.exitCode}; see ${logPath}`);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${baseUrl}/api/v1/staff/ready`);
|
|
||||||
if (response.status === 200) {
|
|
||||||
console.log(
|
|
||||||
`[e2e] server ${name} ready at ${baseUrl} (log: ${path.relative(repoRoot, logPath)})`,
|
|
||||||
);
|
|
||||||
return { child, baseUrl };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// сервер ещё не слушает порт
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
}
|
|
||||||
child.kill('SIGKILL');
|
|
||||||
throw new Error(`server ${name} did not become ready; see ${logPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Откуда брать БД и роли.
|
|
||||||
* 1. E2E_MIGRATOR_URL и E2E_APP_URL заданы — роли и пароли уже есть (общий кластер,
|
|
||||||
* параллельная работа других): стенд их не создаёт и не меняет, БД берётся из URL.
|
|
||||||
* 2. Иначе (по умолчанию) — bootstrap-админ E2E_PG_ADMIN_URL создаёт роли со
|
|
||||||
* случайными паролями и пересоздаёт БД E2E_DATABASE_NAME (scripts/test/provision-pg.mjs).
|
|
||||||
* adminDatabaseUrl — админ в той же БД (нужен только сценарию PILOT-REHOME):
|
|
||||||
* E2E_DB_ADMIN_URL, либо E2E_PG_ADMIN_URL с именем БД стенда.
|
|
||||||
*/
|
|
||||||
function resolveDatabase(): {
|
|
||||||
migrationUrl: string;
|
|
||||||
appUrl: string;
|
|
||||||
adminDatabaseUrl: string | null;
|
|
||||||
} {
|
|
||||||
const adminUrl = process.env.E2E_PG_ADMIN_URL;
|
|
||||||
const withDatabase = (url: string, database: string) => {
|
|
||||||
const result = new URL(url);
|
|
||||||
result.pathname = `/${database}`;
|
|
||||||
return result.toString();
|
|
||||||
};
|
|
||||||
const presetMigrator = process.env.E2E_MIGRATOR_URL;
|
|
||||||
const presetApp = process.env.E2E_APP_URL;
|
|
||||||
if (presetMigrator && presetApp) {
|
|
||||||
const database = new URL(presetApp).pathname.slice(1);
|
|
||||||
console.log(
|
|
||||||
`[e2e] роли заданы E2E_MIGRATOR_URL/E2E_APP_URL: provision пропущен, БД ${database}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
migrationUrl: presetMigrator,
|
|
||||||
appUrl: presetApp,
|
|
||||||
adminDatabaseUrl:
|
|
||||||
process.env.E2E_DB_ADMIN_URL ?? (adminUrl ? withDatabase(adminUrl, database) : null),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (presetMigrator || presetApp) {
|
|
||||||
throw new Error('E2E_MIGRATOR_URL и E2E_APP_URL задаются только вместе');
|
|
||||||
}
|
|
||||||
if (!adminUrl) {
|
|
||||||
throw new Error(
|
|
||||||
'E2E_PG_ADMIN_URL не задан: нужен bootstrap-админ PostgreSQL 17 (только для создания ролей и БД) ' +
|
|
||||||
'или готовые роли в E2E_MIGRATOR_URL + E2E_APP_URL',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const database = process.env.E2E_DATABASE_NAME ?? 'krona_staff_e2e';
|
|
||||||
const secretsDir = mkdtempSync(path.join(tmpdir(), 'krona-e2e-'));
|
|
||||||
const envFile = path.join(secretsDir, 'db.env');
|
|
||||||
runNode(
|
|
||||||
['scripts/test/provision-pg.mjs', '--out', envFile, '--db', database],
|
|
||||||
{ ...process.env, E2E_PG_ADMIN_URL: adminUrl },
|
|
||||||
'provision',
|
|
||||||
);
|
|
||||||
const dbEnv = parseEnvFile(envFile);
|
|
||||||
rmSync(secretsDir, { recursive: true, force: true });
|
|
||||||
if (!dbEnv.DATABASE_MIGRATION_URL || !dbEnv.DATABASE_URL) {
|
|
||||||
throw new Error('provision did not produce connection URLs');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
migrationUrl: dbEnv.DATABASE_MIGRATION_URL,
|
|
||||||
appUrl: dbEnv.DATABASE_URL,
|
|
||||||
adminDatabaseUrl: process.env.E2E_DB_ADMIN_URL ?? withDatabase(adminUrl, database),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function setup(project: TestProject) {
|
|
||||||
mkdirSync(evidenceDir, { recursive: true });
|
|
||||||
const { migrationUrl, appUrl, adminDatabaseUrl } = resolveDatabase();
|
|
||||||
const migrationMode = process.env.E2E_MIGRATIONS ?? 'up';
|
|
||||||
runNode(
|
|
||||||
['scripts/test/migrate-roundtrip.mjs', migrationMode === 'roundtrip' ? 'roundtrip' : 'up'],
|
|
||||||
{ ...baseEnv(), DATABASE_MIGRATION_URL: migrationUrl },
|
|
||||||
`migrations (${migrationMode}) as krona_migrator`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const tenantId = randomUlid();
|
|
||||||
const actorId = randomUlid();
|
|
||||||
const terminalActorId = randomUlid();
|
|
||||||
const managerToken = randomBytes(32).toString('hex');
|
|
||||||
const terminalToken = randomBytes(32).toString('hex');
|
|
||||||
const common = {
|
|
||||||
DATABASE_URL: appUrl,
|
|
||||||
STANDALONE_TENANT_ID: tenantId,
|
|
||||||
STANDALONE_ACTOR_ID: actorId,
|
|
||||||
...vapidEnv(),
|
|
||||||
};
|
|
||||||
const secured = await startServer('secured', {
|
|
||||||
...common,
|
|
||||||
STANDALONE_ACCESS: 'employee',
|
|
||||||
STANDALONE_MANAGER_TOKEN: managerToken,
|
|
||||||
STANDALONE_TERMINAL_TOKEN: terminalToken,
|
|
||||||
STANDALONE_TERMINAL_ACTOR_ID: terminalActorId,
|
|
||||||
});
|
|
||||||
// Второй процесс без токенов (как корневой compose.yaml), тот же tenant и БД: для F2/K-сценариев.
|
|
||||||
const open = await startServer('no-tokens', {
|
|
||||||
...common,
|
|
||||||
STANDALONE_ACCESS: 'manager',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Третий процесс с собственными токенами только для F4 (перебор входа): если rate limit
|
|
||||||
// появится, он не должен блокировать вход в остальных сценариях с того же IP.
|
|
||||||
const rateLimited = await startServer('rate-limit', {
|
|
||||||
...common,
|
|
||||||
STANDALONE_ACCESS: 'employee',
|
|
||||||
STANDALONE_MANAGER_TOKEN: randomBytes(32).toString('hex'),
|
|
||||||
STANDALONE_TERMINAL_TOKEN: randomBytes(32).toString('hex'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const context: E2EContext = {
|
|
||||||
baseUrl: secured.baseUrl,
|
|
||||||
openBaseUrl: open.baseUrl,
|
|
||||||
rateLimitBaseUrl: rateLimited.baseUrl,
|
|
||||||
managerToken,
|
|
||||||
terminalToken,
|
|
||||||
tenantId,
|
|
||||||
actorId,
|
|
||||||
terminalActorId,
|
|
||||||
appDatabaseUrl: appUrl,
|
|
||||||
migrationDatabaseUrl: migrationUrl,
|
|
||||||
adminDatabaseUrl,
|
|
||||||
};
|
|
||||||
project.provide('e2e', context);
|
|
||||||
|
|
||||||
return async () => {
|
|
||||||
for (const server of [secured, open, rateLimited]) {
|
|
||||||
server.child.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
for (const server of [secured, open, rateLimited]) {
|
|
||||||
if (server.child.exitCode === null) server.child.kill('SIGKILL');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
// Проверяемо на Linux: стенд запускает инструменты так, что под Windows не нужен
|
|
||||||
// .cmd-обёрточный файл (spawn без shell падает на них с ENOENT).
|
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { baseEnv, nodeTool, nodeWithTsx, repoRoot } from '../../../scripts/test/node-commands.mjs';
|
|
||||||
|
|
||||||
const forbidden = /\.cmd\b|[\\/]\.bin[\\/]|^npx$|^npm$/i;
|
|
||||||
|
|
||||||
describe('[HARNESS-WIN] запуск инструментов без .cmd-обёрток', () => {
|
|
||||||
it.each(['tsx', 'vitest', 'prettier'] as const)('%s: process.execPath + JS-файл', (tool) => {
|
|
||||||
const command = nodeTool(tool, ['--version']);
|
|
||||||
expect(command.command).toBe(process.execPath);
|
|
||||||
const entry = command.args[0]!;
|
|
||||||
expect(entry).not.toMatch(forbidden);
|
|
||||||
expect(entry).toMatch(/\.(mjs|cjs|js)$/);
|
|
||||||
expect(path.isAbsolute(entry)).toBe(true);
|
|
||||||
expect(existsSync(entry)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('сервер: один процесс node с загрузчиком tsx', () => {
|
|
||||||
const command = nodeWithTsx(['src/server.ts']);
|
|
||||||
expect(command.command).toBe(process.execPath);
|
|
||||||
expect(command.args[0]).toBe('--import');
|
|
||||||
expect(command.args[1]).toMatch(/^file:\/\/.*\.mjs$/);
|
|
||||||
expect(command.args.join(' ')).not.toMatch(forbidden);
|
|
||||||
expect(command.args.at(-1)).toBe('src/server.ts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('под win32 дочерний процесс получает SystemRoot/TEMP, а секреты не наследуются', () => {
|
|
||||||
const env = baseEnv('win32', {
|
|
||||||
PATH: 'C:\\Windows',
|
|
||||||
SystemRoot: 'C:\\Windows',
|
|
||||||
TEMP: 'C:\\Temp',
|
|
||||||
DATABASE_URL: 'secret-must-not-leak',
|
|
||||||
});
|
|
||||||
expect(env).toMatchObject({ PATH: 'C:\\Windows', SystemRoot: 'C:\\Windows', TEMP: 'C:\\Temp' });
|
|
||||||
expect(env).not.toHaveProperty('DATABASE_URL');
|
|
||||||
expect(baseEnv('linux', { PATH: '/bin', SystemRoot: 'x' })).toEqual({ PATH: '/bin' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
'scripts/test/run-e2e.mjs',
|
|
||||||
'scripts/test/n13-race.mjs',
|
|
||||||
'tests/e2e/harness/global-setup.ts',
|
|
||||||
])('%s не вызывает npx и node_modules/.bin', (file) => {
|
|
||||||
const source = readFileSync(path.join(repoRoot, file), 'utf8');
|
|
||||||
expect(source).not.toMatch(/spawn(Sync)?\(\s*['"`]npx/);
|
|
||||||
expect(source).not.toMatch(/node_modules\/\.bin/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md. Ключи — двойные ID «владелец/мой» (docs/audit/2026-09-26/AUDIT.md и TASK-GROK-2 §2), для сценариев без ID владельца — мой ID.",
|
|
||||||
"defects": {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
# Приёмка ветки antigravity/fix-reject-device-shifts @ 795bba0ce8eb2621a395c6bd794d075609c1a3eb (2026-09-26, UTC+7)
|
|
||||||
# Код продукта — ветка исполнителя (git worktree --detach origin/antigravity/fix-reject-device-shifts), поверх — tests/e2e и scripts/test из grok/e2e-harness @ 3475133 (без коммитов в ветку исполнителя).
|
|
||||||
# PostgreSQL 17.11, мигратор NOSUPERUSER, сервер под krona_staff_app.
|
|
||||||
|
|
||||||
$ cd <worktree> && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs
|
|
||||||
== roundtrip OK: rolledBack=11 reapplied=11
|
|
||||||
Tests 43 failed | 11 passed (54)
|
|
||||||
| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
exit=1
|
|
||||||
|
|
||||||
# Было (основная ветка f5ac179 + тот же стенд): Tests 45 failed | 9 passed (54) ; run-e2e OK
|
|
||||||
# Отличия по ID (compare по e2e-results.json, ULID/порты замаскированы):
|
|
||||||
| ID | было | стало | фактический вывод |
|
|
||||||
| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_work_session","status":"applied","result":{"id":"<ulid>","revision":1,"status" / стало: passed |
|
|
||||||
| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"<ulid>","revision":1,"stat / стало: passed |
|
|
||||||
|
|
||||||
# Перепроверка: ветку обновили force-push во время приёмки; новый HEAD 1df2019cb89cad69e2c86608dd3b43e18275e97c (2026-09-26 ~15:40 UTC+7)
|
|
||||||
$ cd <worktree @ 1df2019> && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs
|
|
||||||
== roundtrip OK: rolledBack=11 reapplied=11
|
|
||||||
Tests 43 failed | 11 passed (54)
|
|
||||||
| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
exit=1
|
|
||||||
| ID | было | стало | фактический вывод |
|
|
||||||
| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_work_session","status":"applied","result":{"id":"<ulid>","revision":1,"status" / стало: passed |
|
|
||||||
| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"<ulid>","revision":1,"stat / стало: passed |
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# Приёмка ветки codex/fix-format-baseline @ fd1ec3d8197c21afe15a8dc8101816c52226ebef (2026-09-26, UTC+7)
|
|
||||||
# Код продукта — ветка исполнителя (git worktree --detach origin/codex/fix-format-baseline), поверх — tests/e2e и scripts/test из grok/e2e-harness @ 3475133 (без коммитов в ветку исполнителя).
|
|
||||||
# PostgreSQL 17.11, мигратор NOSUPERUSER, сервер под krona_staff_app.
|
|
||||||
|
|
||||||
$ cd <worktree> && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs
|
|
||||||
== roundtrip OK: rolledBack=11 reapplied=11
|
|
||||||
Tests 43 failed | 11 passed (54)
|
|
||||||
| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
exit=1
|
|
||||||
|
|
||||||
# Было (основная ветка f5ac179 + тот же стенд): Tests 45 failed | 9 passed (54) ; run-e2e OK
|
|
||||||
# Отличия по ID (compare по e2e-results.json, ULID/порты замаскированы):
|
|
||||||
| ID | было | стало | фактический вывод |
|
|
||||||
| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_work_session","status":"applied","result":{"id":"<ulid>","revision":1,"status" / стало: passed |
|
|
||||||
| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"<ulid>","revision":1,"stat / стало: passed |
|
|
||||||
|
|
||||||
# F14: форматирование ветки (оверлей tests/e2e/evidence удалён перед проверкой — в ветке исполнителя нет записи .gitignore для него)
|
|
||||||
$ node node_modules/prettier/bin/prettier.cjs --check .
|
|
||||||
Checking formatting...
|
|
||||||
All matched files use Prettier code style!
|
|
||||||
# Было (основная ветка f5ac179): [warn] Code style issues found in 23 files.
|
|
||||||
|
|
||||||
# Находка: коммит fd1ec3d «style: establish repository formatting baseline» содержит не только форматирование.
|
|
||||||
# Сравнение после нормализации prettier (--config .prettierrc.json), число строк diff:
|
|
||||||
# src/domain/sync/service.ts 331e490 → fd1ec3d: 255; antigravity/fix-reject-device-shifts → fd1ec3d: 0
|
|
||||||
# tests/unit/sync.test.ts 331e490 → fd1ec3d: 139; antigravity/fix-reject-device-shifts → fd1ec3d: 0
|
|
||||||
# src/sync.ts, src/domain/sync/cursor.ts: antigravity → fd1ec3d: 0
|
|
||||||
# То есть в «style»-коммит вошло функциональное исправление Antigravity (отклонение
|
|
||||||
# upsert_work_session/upsert_timesheet_entry, COMMAND_NOT_SUPPORTED) — поэтому K13/SYNC-A/B
|
|
||||||
# зелёные и на этой ветке. Для ревью это смешение тем: изменение поведения скрыто в форматировании.
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
# Приёмка ветки codex/fix-live-database @ ea68e123506cc29276514f67b1dda7cef77057df (2026-09-26, UTC+7)
|
|
||||||
# Код продукта — ветка исполнителя (git worktree --detach origin/codex/fix-live-database), поверх — tests/e2e и scripts/test из grok/e2e-harness @ 3475133 (без коммитов в ветку исполнителя).
|
|
||||||
# PostgreSQL 17.11, мигратор NOSUPERUSER, сервер под krona_staff_app.
|
|
||||||
|
|
||||||
$ cd <worktree> && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs
|
|
||||||
== roundtrip OK: rolledBack=11 reapplied=11
|
|
||||||
Tests 41 failed | 13 passed (54)
|
|
||||||
| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K3/N2 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K4/N1 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
exit=1
|
|
||||||
|
|
||||||
# Было (основная ветка f5ac179 + тот же стенд): Tests 45 failed | 9 passed (54) ; run-e2e OK
|
|
||||||
# Отличия по ID (compare по e2e-results.json, ULID/порты замаскированы):
|
|
||||||
| ID | было | стало | фактический вывод |
|
|
||||||
| A4 | RED | RED | было: AssertionError: тела 500 с деталями PostgreSQL: expected [ …(2) ] to deeply equal [] / стало: AssertionError: тела 500 с деталями PostgreSQL: expected [ { …(2) } ] to deeply equal [] |
|
|
||||||
| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_work_session","status":"applied","result":{"id":"<ulid>","revision":1,"status" / стало: passed |
|
|
||||||
| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"<ulid>","revision":1,"stat / стало: passed |
|
|
||||||
| K3/N2 | RED | GREEN | было: AssertionError: подтверждение смены: HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation / стало: passed |
|
|
||||||
| K4/N1 | RED | GREEN | было: AssertionError: HTTP 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"}: expected 500 to be 201 / стало: passed |
|
|
||||||
| K5/N4 | RED | RED | было: AssertionError: подтверждение смены (предусловие, см. N2): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occ / стало: AssertionError: смена должна быть начислена ровно один раз: expected 196800n to be 98400n // Object.is equality |
|
|
||||||
| K6/D5 | RED | RED | было: AssertionError: подтверждение смены (предусловие; падает, пока открыт K3): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value / стало: AssertionError: после отклонения оплаченной смены должно быть сторно: итог начислений 0: expected 98400n to be 0n // Object.is equality |
|
|
||||||
| K8/D1 | RED | RED | было: AssertionError: подтверждение смены (предусловие; падает, пока открыт K3): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value / стало: AssertionError: C4 verify: HTTP 404 {"message":"Route GET:/api/v1/staff/payroll/runs/<ulid>/verify not found","error":"Not Found","statusCode":404}: expected 404 to be 20 |
|
|
||||||
|
|
||||||
# F14: форматирование ветки (оверлей tests/e2e/evidence удалён перед проверкой — в ветке исполнителя нет записи .gitignore для него)
|
|
||||||
$ node node_modules/prettier/bin/prettier.cjs --check .
|
|
||||||
Checking formatting...
|
|
||||||
All matched files use Prettier code style!
|
|
||||||
# Было (основная ветка f5ac179): [warn] Code style issues found in 23 files.
|
|
||||||
|
|
||||||
# Перепроверка: ветку обновили force-push во время приёмки; новый HEAD b289dd6d63b8f5bcd7c03a88c07200e2e4a704bc (2026-09-26 ~15:40 UTC+7)
|
|
||||||
$ cd <worktree @ b289dd6> && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs
|
|
||||||
== roundtrip OK: rolledBack=11 reapplied=11
|
|
||||||
Tests 41 failed | 13 passed (54)
|
|
||||||
| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K3/N2 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
| K4/N1 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json |
|
|
||||||
exit=1
|
|
||||||
| ID | было | стало | фактический вывод |
|
|
||||||
| A4 | RED | RED | было: AssertionError: тела 500 с деталями PostgreSQL: expected [ …(2) ] to deeply equal [] / стало: AssertionError: тела 500 с деталями PostgreSQL: expected [ { …(2) } ] to deeply equal [] |
|
|
||||||
| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_work_session","status":"applied","result":{"id":"<ulid>","revision":1,"status" / стало: passed |
|
|
||||||
| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-<ulid>","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"<ulid>","revision":1,"stat / стало: passed |
|
|
||||||
| K3/N2 | RED | GREEN | было: AssertionError: подтверждение смены: HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation / стало: passed |
|
|
||||||
| K4/N1 | RED | GREEN | было: AssertionError: HTTP 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"}: expected 500 to be 201 / стало: passed |
|
|
||||||
| K5/N4 | RED | RED | было: AssertionError: подтверждение смены (предусловие, см. N2): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occ / стало: AssertionError: смена должна быть начислена ровно один раз: expected 196800n to be 98400n // Object.is equality |
|
|
||||||
| K6/D5 | RED | RED | было: AssertionError: подтверждение смены (предусловие; падает, пока открыт K3): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value / стало: AssertionError: после отклонения оплаченной смены должно быть сторно: итог начислений 0: expected 98400n to be 0n // Object.is equality |
|
|
||||||
| K8/D1 | RED | RED | было: AssertionError: подтверждение смены (предусловие; падает, пока открыт K3): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value / стало: AssertionError: C4 verify: HTTP 404 {"message":"Route GET:/api/v1/staff/payroll/runs/<ulid>/verify not found","error":"Not Found","statusCode":404}: expected 404 to be 20 |
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
$ node scripts/test/n13-race.mjs --mode as-is --attempts 5
|
|
||||||
[n13] mode=as-is sessions=false N=10 attempt=1: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=10 attempt=2: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=10 attempt=3: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=10 attempt=4: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=10 attempt=5: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=25 attempt=1: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=25 attempt=2: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=25 attempt=3: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=25 attempt=4: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=25 attempt=5: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=50 attempt=1: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=50 attempt=2: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=50 attempt=3: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=50 attempt=4: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD
|
|
||||||
[n13] mode=as-is sessions=false N=50 attempt=5: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD
|
|
||||||
[n13] ИТОГ mode=as-is sessions=false: попыток 15, некорректных 15
|
|
||||||
exit=1
|
|
||||||
|
|
||||||
$ node scripts/test/n13-race.mjs --mode n2-bypass --attempts 5
|
|
||||||
[n13] ДИАГНОСТИКА: staff_outbox.occurred_at DEFAULT now() (обход N2 только в этой БД)
|
|
||||||
[n13] mode=n2-bypass sessions=false N=10 attempt=1: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=10 attempt=2: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=10 attempt=3: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=10 attempt=4: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=10 attempt=5: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=25 attempt=1: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=25 attempt=2: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=25 attempt=3: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=25 attempt=4: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=25 attempt=5: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=50 attempt=1: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=50 attempt=2: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=50 attempt=3: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=50 attempt=4: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=false N=50 attempt=5: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] ИТОГ mode=n2-bypass sessions=false: попыток 15, некорректных 0
|
|
||||||
exit=0
|
|
||||||
|
|
||||||
$ node scripts/test/n13-race.mjs --mode n2-bypass --attempts 5 --with-sessions --db krona_staff_n13_sessions
|
|
||||||
[n13] ДИАГНОСТИКА: staff_outbox.occurred_at DEFAULT now() (обход N2 только в этой БД)
|
|
||||||
[n13] mode=n2-bypass sessions=true N=10 attempt=1: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=10 attempt=2: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=10 attempt=3: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=10 attempt=4: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=10 attempt=5: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=25 attempt=1: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=25 attempt=2: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=25 attempt=3: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=25 attempt=4: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=25 attempt=5: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=50 attempt=1: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=50 attempt=2: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=50 attempt=3: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=50 attempt=4: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] mode=n2-bypass sessions=true N=50 attempt=5: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK
|
|
||||||
[n13] ИТОГ mode=n2-bypass sessions=true: попыток 15, некорректных 0
|
|
||||||
exit=0
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
== run 1: DATABASE_TEST_URL=<krona_staff_app@127.0.0.1:55433/krona_staff_e2e> DATABASE_TEST_MIGRATION_URL=<krona_migrator@...> npx vitest run tests/integration/tenant-isolation.test.ts
|
|
||||||
|
|
||||||
RUN v5.0.1 /workspace/krona-staff
|
|
||||||
|
|
||||||
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 1 passed (1)
|
|
||||||
Start at 14:20:56
|
|
||||||
Duration 275ms (tests 63%, transform 23%, import 12%, worker 1%)
|
|
||||||
|
|
||||||
exit=0
|
|
||||||
== run 2: DATABASE_TEST_URL=<krona_staff_app@127.0.0.1:55433/krona_staff_e2e> DATABASE_TEST_MIGRATION_URL=<krona_migrator@...> npx vitest run tests/integration/tenant-isolation.test.ts
|
|
||||||
|
|
||||||
RUN v5.0.1 /workspace/krona-staff
|
|
||||||
|
|
||||||
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 1 passed (1)
|
|
||||||
Start at 14:20:57
|
|
||||||
Duration 260ms (tests 62%, transform 23%, import 13%, worker 2%)
|
|
||||||
|
|
||||||
exit=0
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
== DATABASE_TEST_URL=<krona_staff_app@127.0.0.1:55433/krona_staff_e2e> npx vitest run tests/integration/tenant-isolation.test.ts (база 778ba2d, до исправления)
|
|
||||||
|
|
||||||
RUN v5.0.1 /workspace/krona-staff
|
|
||||||
|
|
||||||
❯ tests/integration/tenant-isolation.test.ts (1 test) 114ms
|
|
||||||
|
|
||||||
⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
|
|
||||||
|
|
||||||
FAIL tests/integration/tenant-isolation.test.ts > PostgreSQL tenant isolation
|
|
||||||
error: permission denied for table staff_employees
|
|
||||||
❯ node_modules/pg/lib/client.js:694:17
|
|
||||||
❯ tests/integration/tenant-isolation.test.ts:40:7
|
|
||||||
38| await client.query('DELETE FROM staff_outbox WHERE id = $1', [ev…
|
|
||||||
39| await client.query('DELETE FROM staff_professions WHERE id = $1'…
|
|
||||||
40| await client.query('DELETE FROM staff_employees WHERE id = $1', …
|
|
||||||
| ^
|
|
||||||
41| });
|
|
||||||
42| await database.close();
|
|
||||||
❯ Database.tenantTransaction src/infrastructure/database.ts:30:22
|
|
||||||
❯ tests/integration/tenant-isolation.test.ts:31:5
|
|
||||||
|
|
||||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
|
||||||
|
|
||||||
|
|
||||||
Test Files 1 failed (1)
|
|
||||||
Tests 1 passed (1)
|
|
||||||
Start at 14:20:47
|
|
||||||
Duration 265ms (tests 62%, transform 23%, import 14%, worker 1%)
|
|
||||||
|
|
||||||
exit=1
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
# Команда: node scripts/test/accept-branch.mjs $(все origin/codex/* и origin/antigravity/* кроме основы) — после git fetch в 16:16 NOVT
|
|
||||||
|
|
||||||
## accept-branch — 2026-09-26 09:20 UTC
|
|
||||||
|
|
||||||
База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): 6f408bc; БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-16-26/`.
|
|
||||||
|
|
||||||
### Сводка
|
|
||||||
|
|
||||||
| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |
|
|
||||||
| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------- | ------------------ | ------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | n/a | n/a |
|
|
||||||
| origin/antigravity/employee-notifications | 3e20544 | OK (164 passed | 1 skipped (165)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 49/2 | — | HARNESS (AssertionError: expected 10 to be greater than or equal to 11); PILOT-REHOME-SEED (error: relation "staff_sync_change_log" does not exist); SYNC-L (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/sync/devices not found","error":"Not Found","statusCode":404}: expected 404 to be 201 // Object.is) |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | b283736 | OK (196 passed | 3 skipped (199)) | 4/5 зелёных; FAIL: tenant-isolation.test.ts | 44/7 | K13/SYNC-A, K13/SYNC-B | — |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | 24cc18d | OK (197 passed | 5 skipped (202)) | 5/6 зелёных; FAIL: tenant-isolation.test.ts | 43/8 | K13/SYNC-A, K13/SYNC-B, SYNC-C0 | — |
|
|
||||||
| origin/antigravity/offline-sync-backend | 5ee7517 | OK (192 passed | 1 skipped (193)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | — | — |
|
|
||||||
| origin/codex/finance-calendar-dashboard | c08a7e6 | OK (113 passed | 1 skipped (114)) | 4/4 зелёных | 48/3 | A4, K4/N1 | CTRL (AssertionError: expected 200 to be 401 // Object.is equality); HARNESS (AssertionError: expected 8 to be greater than or equal to 11); PILOT-REHOME-SEED (error: relation "staff_professions" does not exist); SYNC-L (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/sync/devices not found","error":"Not Found","statusCode":404}: expected 404 to be 201 // Object.is) |
|
|
||||||
| origin/codex/fix-format-baseline | a05777c | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | — | — |
|
|
||||||
| origin/codex/fix-live-database | 1573860 | OK (197 passed | 2 skipped (199)) | 4/5 зелёных; FAIL: tenant-isolation.test.ts | 44/7 | K3/N2, K4/N1 | — |
|
|
||||||
| origin/codex/frontend-library | 331e490 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | — | — |
|
|
||||||
| origin/codex/security-finance-production | f74aa6d | OK (142 passed | 1 skipped (143)) | 4/4 зелёных | 49/2 | — | HARNESS (AssertionError: expected 9 to be greater than or equal to 11); PILOT-REHOME-SEED (error: relation "staff_push_subscriptions" does not exist); SYNC-L (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/sync/devices not found","error":"Not Found","statusCode":404}: expected 404 to be 201 // Object.is) |
|
|
||||||
| origin/codex/server-web-production | 2f6b367 | OK (113 passed | 1 skipped (114)) | 4/4 зелёных | 49/2 | K4/N1 | CTRL (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/imports/owner-workbook not found","error":"Not Found","statusCode":404}: expected 404 to be less t); HARNESS (AssertionError: expected 7 to be greater than or equal to 11); PILOT-REHOME-SEED (error: relation "staff_professions" does not exist); SYNC-L (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/sync/devices not found","error":"Not Found","statusCode":404}: expected 404 to be 201 // Object.is) |
|
|
||||||
|
|
||||||
### Интеграционные тесты (каждый файл отдельно, свежая БД)
|
|
||||||
|
|
||||||
| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |
|
|
||||||
| ------------------------------------------- | --------------------------------- | ---- | ------------ | -------------- | -------------------------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/antigravity/employee-notifications | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/antigravity/employee-notifications | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/antigravity/employee-notifications | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/antigravity/employee-notifications | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | sync-bootstrap.test.ts | OK | 1 passed (1) | 2 passed (2) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/antigravity/offline-sync-backend | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/antigravity/offline-sync-backend | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/antigravity/offline-sync-backend | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/antigravity/offline-sync-backend | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/codex/finance-calendar-dashboard | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/finance-calendar-dashboard | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/finance-calendar-dashboard | portal.test.ts | OK | 1 passed (1) | 11 passed (11) | |
|
|
||||||
| origin/codex/finance-calendar-dashboard | tenant-isolation.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/fix-format-baseline | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/fix-format-baseline | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/fix-format-baseline | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/fix-format-baseline | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/codex/fix-live-database | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/fix-live-database | live-postgres-remediation.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/fix-live-database | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/fix-live-database | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/fix-live-database | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/codex/frontend-library | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/frontend-library | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/frontend-library | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/frontend-library | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/codex/security-finance-production | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/security-finance-production | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/security-finance-production | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/security-finance-production | tenant-isolation.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/server-web-production | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/server-web-production | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/server-web-production | portal.test.ts | OK | 1 passed (1) | 11 passed (11) | |
|
|
||||||
| origin/codex/server-web-production | tenant-isolation.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
|
|
||||||
npm ci (lock отличается): origin/codex/finance-calendar-dashboard, origin/codex/security-finance-production, origin/codex/server-web-production
|
|
||||||
|
|
||||||
### Попарные слияния (`git merge-tree --write-tree`)
|
|
||||||
|
|
||||||
| Ветка A | Ветка B | Результат | Конфликтные файлы |
|
|
||||||
| ------------------------------------------- | ------------------------------------------- | --------- | ----------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/antigravity/employee-notifications | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-reject-device-shifts | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-sync-c0-bootstrap | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/antigravity/offline-sync-backend | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/codex/finance-calendar-dashboard | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/codex/fix-live-database | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/codex/frontend-library | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/antigravity/fix-reject-device-shifts | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/antigravity/fix-sync-c0-bootstrap | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/antigravity/offline-sync-backend | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/codex/finance-calendar-dashboard | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/codex/fix-live-database | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/codex/frontend-library | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/antigravity/employee-notifications | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/antigravity/fix-sync-c0-bootstrap | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/antigravity/offline-sync-backend | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/codex/finance-calendar-dashboard | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/codex/fix-live-database | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/codex/frontend-library | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/antigravity/offline-sync-backend | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/finance-calendar-dashboard | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/fix-live-database | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/frontend-library | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/antigravity/offline-sync-backend | origin/codex/finance-calendar-dashboard | чисто | |
|
|
||||||
| origin/antigravity/offline-sync-backend | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/antigravity/offline-sync-backend | origin/codex/fix-live-database | чисто | |
|
|
||||||
| origin/antigravity/offline-sync-backend | origin/codex/frontend-library | чисто | |
|
|
||||||
| origin/antigravity/offline-sync-backend | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/antigravity/offline-sync-backend | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/codex/finance-calendar-dashboard | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/codex/finance-calendar-dashboard | origin/codex/fix-live-database | чисто | |
|
|
||||||
| origin/codex/finance-calendar-dashboard | origin/codex/frontend-library | чисто | |
|
|
||||||
| origin/codex/finance-calendar-dashboard | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/codex/finance-calendar-dashboard | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/codex/fix-format-baseline | origin/codex/fix-live-database | чисто | |
|
|
||||||
| origin/codex/fix-format-baseline | origin/codex/frontend-library | чисто | |
|
|
||||||
| origin/codex/fix-format-baseline | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/codex/fix-format-baseline | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/codex/fix-live-database | origin/codex/frontend-library | чисто | |
|
|
||||||
| origin/codex/fix-live-database | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/codex/fix-live-database | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/codex/frontend-library | origin/codex/security-finance-production | чисто | |
|
|
||||||
| origin/codex/frontend-library | origin/codex/server-web-production | чисто | |
|
|
||||||
| origin/codex/security-finance-production | origin/codex/server-web-production | чисто | |
|
|
||||||
|
|
||||||
### Форматирование без поведения
|
|
||||||
|
|
||||||
**origin/codex/fix-format-baseline** (a05777c), merge-base f5ac179, изменено файлов: 19 — ТОЛЬКО ФОРМАТИРОВАНИЕ
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
# Команда: node scripts/test/accept-branch.mjs antigravity/fix-reject-device-shifts antigravity/fix-sync-c0-bootstrap codex/fix-format-baseline (16:03 NOVT; ветка Antigravity к этому времени уже 6c5bf36; версия скрипта до вердикта SKIP)
|
|
||||||
|
|
||||||
## accept-branch — 2026-09-26 09:04 UTC
|
|
||||||
|
|
||||||
База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): aa7f5f8 (+ незакоммиченные изменения стенда); БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-03-17/`.
|
|
||||||
|
|
||||||
### Сводка
|
|
||||||
|
|
||||||
| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |
|
|
||||||
| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------------------------------- | ------------------ | ---------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 45/4 | n/a | n/a |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | 6c5bf36 | OK (196 passed | 3 skipped (199)) | 4/5 зелёных; FAIL: tenant-isolation.test.ts | 43/6 | K13/SYNC-A, K13/SYNC-B | — |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | 8baad55 | OK (196 passed | 3 skipped (199)) | 3/5 зелёных; FAIL: sync-bootstrap.test.ts, tenant-isolation.test.ts | 44/5 | SYNC-C0 | — |
|
|
||||||
| origin/codex/fix-format-baseline | 513cdff | OK (196 passed | 1 skipped (197)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 43/6 | K13/SYNC-A, K13/SYNC-B | — |
|
|
||||||
|
|
||||||
### Интеграционные тесты (каждый файл отдельно, свежая БД)
|
|
||||||
|
|
||||||
| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |
|
|
||||||
| ------------------------------------------- | ------------------------ | ---- | ------------ | -------------- | -------------------------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | sync-bootstrap.test.ts | FAIL | 1 failed (1) | 2 passed (2) | Error: Called end on pool more than once |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/codex/fix-format-baseline | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/fix-format-baseline | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/fix-format-baseline | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/fix-format-baseline | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
|
|
||||||
### Попарные слияния (`git merge-tree --write-tree`)
|
|
||||||
|
|
||||||
| Ветка A | Ветка B | Результат | Конфликтные файлы |
|
|
||||||
| ------------------------------------------- | ------------------------------------------- | --------- | -------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-reject-device-shifts | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-sync-c0-bootstrap | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/antigravity/fix-sync-c0-bootstrap | КОНФЛИКТ | src/domain/sync/service.ts |
|
|
||||||
| origin/antigravity/fix-reject-device-shifts | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/fix-format-baseline | КОНФЛИКТ | src/domain/sync/service.ts |
|
|
||||||
|
|
||||||
### Форматирование без поведения
|
|
||||||
|
|
||||||
**origin/codex/fix-format-baseline** (513cdff), merge-base f5ac179, изменено файлов: 24 — ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER
|
|
||||||
|
|
||||||
| Файл | Почему | Строк сверх prettier | Эти же строки в ветке |
|
|
||||||
| -------------------------- | ----------------------------- | -------------------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| src/domain/sync/service.ts | не совпадает с prettier(база) | 232 | antigravity/fix-reject-device-shifts: 232/232; antigravity/fix-sync-c0-bootstrap: 6/232 |
|
|
||||||
| tests/unit/sync.test.ts | не совпадает с prettier(база) | 130 | antigravity/fix-reject-device-shifts: 130/130; antigravity/fix-sync-c0-bootstrap: 12/130 |
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
# Команда: node scripts/test/accept-branch.mjs 1df2019 antigravity/fix-sync-c0-bootstrap codex/fix-format-baseline (SHA 1df2019 = прежняя голова antigravity/fix-reject-device-shifts; 8baad55, 513cdff — головы на момент прогона, 16:04 NOVT)
|
|
||||||
|
|
||||||
## accept-branch — 2026-09-26 09:06 UTC
|
|
||||||
|
|
||||||
База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): aa7f5f8 (+ незакоммиченные изменения стенда); БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-05-12/`.
|
|
||||||
|
|
||||||
### Сводка
|
|
||||||
|
|
||||||
| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |
|
|
||||||
| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------------------------------- | ------------------ | ---------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 45/4 | n/a | n/a |
|
|
||||||
| 1df2019 | 1df2019 | OK (196 passed | 3 skipped (199)) | 3/5 зелёных; FAIL: sync-rejection.test.ts, tenant-isolation.test.ts | 43/6 | K13/SYNC-A, K13/SYNC-B | — |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | 8baad55 | OK (196 passed | 3 skipped (199)) | 3/5 зелёных; FAIL: sync-bootstrap.test.ts, tenant-isolation.test.ts | 44/5 | SYNC-C0 | — |
|
|
||||||
| origin/codex/fix-format-baseline | 513cdff | OK (196 passed | 1 skipped (197)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 43/6 | K13/SYNC-A, K13/SYNC-B | — |
|
|
||||||
|
|
||||||
### Интеграционные тесты (каждый файл отдельно, свежая БД)
|
|
||||||
|
|
||||||
| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |
|
|
||||||
| ------------------------------------------ | ------------------------ | ---- | ------------ | -------------- | ----------------------------------------------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| 1df2019 | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| 1df2019 | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| 1df2019 | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| 1df2019 | sync-rejection.test.ts | FAIL | 1 failed (1) | 2 skipped (2) | error: column "created_by" of relation "staff_employees" does not exist |
|
|
||||||
| 1df2019 | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | sync-bootstrap.test.ts | FAIL | 1 failed (1) | 2 passed (2) | Error: Called end on pool more than once |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/codex/fix-format-baseline | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/fix-format-baseline | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/fix-format-baseline | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/fix-format-baseline | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
|
|
||||||
### Попарные слияния (`git merge-tree --write-tree`)
|
|
||||||
|
|
||||||
| Ветка A | Ветка B | Результат | Конфликтные файлы |
|
|
||||||
| ------------------------------------------ | ---------------------------------------- | --------- | -------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | 1df2019 | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-sync-c0-bootstrap | чисто | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| 1df2019 | origin/antigravity/fix-sync-c0-bootstrap | КОНФЛИКТ | src/domain/sync/service.ts |
|
|
||||||
| 1df2019 | origin/codex/fix-format-baseline | чисто | |
|
|
||||||
| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/fix-format-baseline | КОНФЛИКТ | src/domain/sync/service.ts |
|
|
||||||
|
|
||||||
### Форматирование без поведения
|
|
||||||
|
|
||||||
**origin/codex/fix-format-baseline** (513cdff), merge-base f5ac179, изменено файлов: 24 — ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER
|
|
||||||
|
|
||||||
| Файл | Почему | Строк сверх prettier | Эти же строки в ветке |
|
|
||||||
| -------------------------- | ----------------------------- | -------------------- | ----------------------------------------------------------- |
|
|
||||||
| src/domain/sync/service.ts | не совпадает с prettier(база) | 232 | 1df2019: 232/232; antigravity/fix-sync-c0-bootstrap: 6/232 |
|
|
||||||
| tests/unit/sync.test.ts | не совпадает с prettier(база) | 130 | 1df2019: 130/130; antigravity/fix-sync-c0-bootstrap: 12/130 |
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
# Локальные ветки (не запушены): tmp/combined-round3 = b0243d5 + merge origin/codex/fix-live-database (1573860) + merge origin/antigravity/fix-sync-c0-bootstrap (24cc18d), без конфликтов; tmp/combined-round3-grok = то же + merge grok/e2e-harness (6f408bc). Команда: node scripts/test/accept-branch.mjs <ветка>
|
|
||||||
|
|
||||||
## accept-branch — 2026-09-26 09:22 UTC
|
|
||||||
|
|
||||||
База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): 6f408bc; БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-22-01/`.
|
|
||||||
|
|
||||||
### Сводка
|
|
||||||
|
|
||||||
| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |
|
|
||||||
| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------- | ------------------ | --------------------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | n/a | n/a |
|
|
||||||
| tmp/combined-round3 | 48128c8 | OK (199 passed | 6 skipped (205)) | 6/7 зелёных; FAIL: tenant-isolation.test.ts | 41/10 | K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1, SYNC-C0 | — |
|
|
||||||
|
|
||||||
### Интеграционные тесты (каждый файл отдельно, свежая БД)
|
|
||||||
|
|
||||||
| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |
|
|
||||||
| ------------------------------------------ | --------------------------------- | ---- | ------------ | -------------- | -------------------------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| tmp/combined-round3 | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| tmp/combined-round3 | live-postgres-remediation.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| tmp/combined-round3 | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| tmp/combined-round3 | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| tmp/combined-round3 | sync-bootstrap.test.ts | OK | 1 passed (1) | 2 passed (2) | |
|
|
||||||
| tmp/combined-round3 | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | |
|
|
||||||
| tmp/combined-round3 | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
|
|
||||||
### Попарные слияния (`git merge-tree --write-tree`)
|
|
||||||
|
|
||||||
| Ветка A | Ветка B | Результат | Конфликтные файлы |
|
|
||||||
| ------------------------------------------ | ------------------- | --------- | ----------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | tmp/combined-round3 | чисто | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## accept-branch — 2026-09-26 09:23 UTC
|
|
||||||
|
|
||||||
База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): 6f408bc; БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-23-04/`.
|
|
||||||
|
|
||||||
### Сводка
|
|
||||||
|
|
||||||
| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |
|
|
||||||
| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------- | ------------------ | --------------------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | n/a | n/a |
|
|
||||||
| tmp/combined-round3-grok | e94415a | OK (199 passed | 6 skipped (205)) | 7/7 зелёных | 41/10 | K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1, SYNC-C0 | — |
|
|
||||||
|
|
||||||
### Интеграционные тесты (каждый файл отдельно, свежая БД)
|
|
||||||
|
|
||||||
| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |
|
|
||||||
| ------------------------------------------ | --------------------------------- | ---- | ------------ | -------------- | -------------------------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| tmp/combined-round3-grok | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| tmp/combined-round3-grok | live-postgres-remediation.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| tmp/combined-round3-grok | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| tmp/combined-round3-grok | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| tmp/combined-round3-grok | sync-bootstrap.test.ts | OK | 1 passed (1) | 2 passed (2) | |
|
|
||||||
| tmp/combined-round3-grok | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | |
|
|
||||||
| tmp/combined-round3-grok | tenant-isolation.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
|
|
||||||
Внимание: ветка меняла файлы стенда, при приёмке они заменены оверлеем:
|
|
||||||
|
|
||||||
- tmp/combined-round3-grok: scripts/test/accept-branch.mjs, scripts/test/ci-local.sh, scripts/test/migrate-roundtrip.mjs, scripts/test/n13-race.mjs, scripts/test/node-commands.d.mts, scripts/test/node-commands.mjs, scripts/test/provision-pg.mjs, scripts/test/run-e2e.mjs, tests/e2e/00-harness.e2e.ts, tests/e2e/10-core-defects.e2e.ts, tests/e2e/20-sync-defects.e2e.ts, tests/e2e/30-owner-decisions.e2e.ts, tests/e2e/40-compose-pilot.e2e.ts, tests/e2e/50-money-and-errors.e2e.ts, tests/e2e/60-pilot-rehome.e2e.ts, tests/e2e/NOTES-FOR-EXECUTORS.md, tests/e2e/REPORT.md, tests/e2e/harness/client.ts, tests/e2e/harness/context.ts, tests/e2e/harness/fixtures.ts, tests/e2e/harness/global-setup.ts, tests/e2e/harness/node-commands.test.ts, tests/e2e/known-defects.json, tests/e2e/report-evidence/acceptance-antigravity-fix-reject-device-shifts.txt, tests/e2e/report-evidence/acceptance-codex-fix-format-baseline.txt, tests/e2e/report-evidence/acceptance-codex-fix-live-database.txt, tests/e2e/report-evidence/n13-race.txt, tests/e2e/report-evidence/n5-after.txt, tests/e2e/report-evidence/n5-before.txt, tests/e2e/report-evidence/round3-pilot-rehome.txt, tests/e2e/vitest.e2e.config.ts
|
|
||||||
|
|
||||||
### Попарные слияния (`git merge-tree --write-tree`)
|
|
||||||
|
|
||||||
| Ветка A | Ветка B | Результат | Конфликтные файлы |
|
|
||||||
| ------------------------------------------ | ------------------------ | --------- | ----------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | tmp/combined-round3-grok | чисто | |
|
|
||||||
|
|
||||||
```
|
|
||||||
[run-e2e] FAIL:
|
|
||||||
- K13/SYNC-A: дефект исправлен, обновите known-defects.json
|
|
||||||
- K13/SYNC-B: дефект исправлен, обновите known-defects.json
|
|
||||||
- K3/N2: дефект исправлен, обновите known-defects.json
|
|
||||||
- K4/N1: дефект исправлен, обновите known-defects.json
|
|
||||||
- SYNC-C0: дефект исправлен, обновите known-defects.json
|
|
||||||
```
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# Команда: node scripts/test/accept-branch.mjs --format-check tmp/prettier-only-f5ac179 --format-check codex/fix-format-baseline (tmp/prettier-only-f5ac179 = c552d38: `prettier --write .` поверх f5ac179, локальная ветка, не запушена; 513cdff — прежняя голова codex/fix-format-baseline)
|
|
||||||
|
|
||||||
[accept] tmp/prettier-only-f5ac179: проверка форматирования
|
|
||||||
[accept] codex/fix-format-baseline: проверка форматирования
|
|
||||||
|
|
||||||
## accept-branch — 2026-09-26 09:03 UTC
|
|
||||||
|
|
||||||
База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): aa7f5f8 (+ незакоммиченные изменения стенда); БД: не использовалась. Логи: `tests/e2e/evidence/accept/2026-09-26T09-03-11/`.
|
|
||||||
|
|
||||||
### Форматирование без поведения
|
|
||||||
|
|
||||||
**tmp/prettier-only-f5ac179** (c552d38), merge-base f5ac179, изменено файлов: 24 — ТОЛЬКО ФОРМАТИРОВАНИЕ
|
|
||||||
|
|
||||||
**origin/codex/fix-format-baseline** (513cdff), merge-base f5ac179, изменено файлов: 24 — ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER
|
|
||||||
|
|
||||||
| Файл | Почему | Строк сверх prettier | Эти же строки в ветке |
|
|
||||||
| -------------------------- | ----------------------------- | -------------------- | --------------------- |
|
|
||||||
| src/domain/sync/service.ts | не совпадает с prettier(база) | 232 | — |
|
|
||||||
| tests/unit/sync.test.ts | не совпадает с prettier(база) | 130 | — |
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
# PILOT-REHOME: доказательство, что сценарий ловит ошибки переноса (TASK-GROK-3 п.5)
|
|
||||||
# Эталон — временный scripts/rehome-tenant.mjs (минимальный UPDATE tenant_id под session_replication_role=replica + запись аудита);
|
|
||||||
# он НЕ коммитился и удалён после прогона. Команда: node scripts/test/run-e2e.mjs (PG17, admin-режим).
|
|
||||||
|
|
||||||
== эталон (reference), не коммитится
|
|
||||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 176ms
|
|
||||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 340ms
|
|
||||||
× tests/e2e/30-owner-decisions.e2e.ts > Решения владельца: роли, пакеты зарплаты, согласие на биометрию > [D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403 111ms
|
|
||||||
== эталон (no-audit), не коммитится
|
|
||||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 164ms
|
|
||||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 191ms
|
|
||||||
→ staff_audit_log: у нового арендатора нет новой записи о переносе: expected 2 to be greater than 2
|
|
||||||
FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает
|
|
||||||
== эталон (dry-writes), не коммитится
|
|
||||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 182ms
|
|
||||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 88ms
|
|
||||||
→ --dry-run изменил данные заглушки: expected { counts: { …(27) }, …(1) } to deeply equal { counts: { …(27) }, …(1) }
|
|
||||||
FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает
|
|
||||||
== без скрипта
|
|
||||||
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 169ms
|
|
||||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 4ms
|
|
||||||
→ scripts/rehome-tenant.mjs отсутствует (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 → 01M2R6AC5652NPT3MG34DKN4ZP не реализован; контракт сценария — NOTES-FOR-EXECUTORS.md C6. Засеяно таблиц: 27, ledger SUM(amount_minor)=313227
|
|
||||||
FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает
|
|
||||||
|
|
||||||
# Мутации эталона (первый прогон, до уточнения сообщений ассертов)
|
|
||||||
== мутация эталона: no-audit
|
|
||||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 196ms
|
|
||||||
→ expected 2 to be greater than 2
|
|
||||||
== мутация эталона: no-refuse
|
|
||||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 319ms
|
|
||||||
→ повторный перенос должен отказать: expected +0 not to be +0 // Object.is equality
|
|
||||||
== мутация эталона: skip-table
|
|
||||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 195ms
|
|
||||||
→ под заглушкой остались строки: expected [ 'staff_work_items' ] to deeply equal []
|
|
||||||
== мутация эталона: dry-writes
|
|
||||||
× tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 91ms
|
|
||||||
→ expected { counts: { …(27) }, …(1) } to deeply equal { counts: { …(27) }, …(1) }
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# Режим готовых ролей: E2E_MIGRATOR_URL/E2E_APP_URL заданы, E2E_PG_ADMIN_URL не задан. Команда: node scripts/test/accept-branch.mjs --skip-check codex/fix-live-database (8e955b0)
|
|
||||||
|
|
||||||
База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): d7b2352 (+ незакоммиченные изменения стенда); БД: роли из E2E_MIGRATOR_URL/E2E_APP_URL (не пересоздавались). Логи: `tests/e2e/evidence/accept/2026-09-26T09-15-44/`.
|
|
||||||
|
|
||||||
### Сводка
|
|
||||||
|
|
||||||
| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |
|
|
||||||
| ------------------------------------------------- | ------- | ------------- | ------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------ | ------------------ |
|
|
||||||
| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | не запускался | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | n/a | n/a |
|
|
||||||
| origin/codex/fix-live-database | 8e955b0 | не запускался | 3/5 зелёных; FAIL: tenant-isolation.test.ts; SKIP (файл пропущен целиком): live-postgres-remediation.test.ts | 42/9 | K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1 | — |
|
|
||||||
|
|
||||||
### Интеграционные тесты (каждый файл отдельно, свежая БД)
|
|
||||||
|
|
||||||
| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |
|
|
||||||
| ------------------------------------------ | --------------------------------- | ---- | ------------- | -------------- | -------------------------------------------------- |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
| origin/codex/fix-live-database | health.test.ts | OK | 1 passed (1) | 3 passed (3) | |
|
|
||||||
| origin/codex/fix-live-database | live-postgres-remediation.test.ts | SKIP | 1 skipped (1) | 1 skipped (1) | |
|
|
||||||
| origin/codex/fix-live-database | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | |
|
|
||||||
| origin/codex/fix-live-database | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | |
|
|
||||||
| origin/codex/fix-live-database | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees |
|
|
||||||
|
|
||||||
### Попарные слияния (`git merge-tree --write-tree`)
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
// Отдельный конфиг: e2e-файлы названы *.e2e.ts и не попадают в `npm test`
|
|
||||||
// (корневой vitest ищет только *.test.ts), поэтому `npm run check` не зависит от
|
|
||||||
// живой PostgreSQL и от известных открытых дефектов.
|
|
||||||
const root = fileURLToPath(new URL('../..', import.meta.url));
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
root,
|
|
||||||
test: {
|
|
||||||
include: ['tests/e2e/**/*.e2e.ts'],
|
|
||||||
globalSetup: ['tests/e2e/harness/global-setup.ts'],
|
|
||||||
fileParallelism: false,
|
|
||||||
testTimeout: 60_000,
|
|
||||||
hookTimeout: 180_000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { loadConfig } from '../../src/config.js';
|
||||||
|
|
||||||
const config = loadConfig({
|
const config = loadConfig({
|
||||||
DATABASE_URL: 'postgres://localhost/staff',
|
DATABASE_URL: 'postgres://localhost/staff',
|
||||||
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ function assertOpenApiDocument(value: unknown): asserts value is OpenApiDocument
|
||||||
|
|
||||||
const config = loadConfig({
|
const config = loadConfig({
|
||||||
DATABASE_URL: 'postgres://localhost/staff',
|
DATABASE_URL: 'postgres://localhost/staff',
|
||||||
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
@ -54,7 +54,6 @@ describe('OpenAPI runtime contract', () => {
|
||||||
.replace('{employeeId}', '01J00000000000000000000000')
|
.replace('{employeeId}', '01J00000000000000000000000')
|
||||||
.replace('{workDate}', '2026-09-24')
|
.replace('{workDate}', '2026-09-24')
|
||||||
.replace('{runId}', '01J00000000000000000000000')
|
.replace('{runId}', '01J00000000000000000000000')
|
||||||
.replace('{id}', '01J00000000000000000000000')
|
|
||||||
.replace('{deviceId}', '01J00000000000000000000000');
|
.replace('{deviceId}', '01J00000000000000000000000');
|
||||||
const operations = documented.paths[path] as Record<string, unknown>;
|
const operations = documented.paths[path] as Record<string, unknown>;
|
||||||
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { loadConfig } from '../../src/config.js';
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
DATABASE_URL: 'postgres://localhost/staff',
|
DATABASE_URL: 'postgres://localhost/staff',
|
||||||
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
@ -98,33 +98,6 @@ describe('attendance portal', () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rate-limits repeated failed browser logins', async () => {
|
|
||||||
const { database } = fakeDatabase([]);
|
|
||||||
const app = await buildApp({
|
|
||||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
|
||||||
database,
|
|
||||||
});
|
|
||||||
|
|
||||||
let response;
|
|
||||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
||||||
response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/staff/session',
|
|
||||||
payload: { access_token: 'x'.repeat(64) },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/staff/session',
|
|
||||||
payload: { access_token: 'x'.repeat(64) },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(429);
|
|
||||||
expect(response.headers['retry-after']).toBeDefined();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scopes an employee portal to the linked Core user', async () => {
|
it('scopes an employee portal to the linked Core user', async () => {
|
||||||
const { database, calls } = fakeDatabase([[], [], []]);
|
const { database, calls } = fakeDatabase([[], [], []]);
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
|
|
@ -240,10 +213,9 @@ describe('attendance portal', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(201);
|
expect(response.statusCode).toBe(201);
|
||||||
expect(calls).toHaveLength(3);
|
expect(calls).toHaveLength(2);
|
||||||
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
|
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
|
||||||
expect(calls[1]?.[0]).toContain('staff_audit_log');
|
expect(calls[1]?.[0]).toContain('staff_audit_log');
|
||||||
expect(calls[2]?.[0]).toContain('staff_sync_change_log');
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -274,7 +246,7 @@ describe('attendance portal', () => {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
};
|
};
|
||||||
const after = { ...before, position: 'Старший администратор' };
|
const after = { ...before, position: 'Старший администратор' };
|
||||||
const { database, calls } = fakeDatabase([[before], [], [after], [], []]);
|
const { database, calls } = fakeDatabase([[before], [], [after], []]);
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||||
database,
|
database,
|
||||||
|
|
@ -286,11 +258,10 @@ describe('attendance portal', () => {
|
||||||
payload: { position: 'Старший администратор' },
|
payload: { position: 'Старший администратор' },
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(calls).toHaveLength(5);
|
expect(calls).toHaveLength(4);
|
||||||
expect(calls[0]?.[0]).toContain('FOR UPDATE');
|
expect(calls[0]?.[0]).toContain('FOR UPDATE');
|
||||||
expect(calls[3]?.[1]?.[4]).toEqual(before);
|
expect(calls[3]?.[1]?.[4]).toEqual(before);
|
||||||
expect(calls[3]?.[1]?.[5]).toEqual(after);
|
expect(calls[3]?.[1]?.[5]).toEqual(after);
|
||||||
expect(calls[4]?.[0]).toContain('staff_sync_change_log');
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,548 +0,0 @@
|
||||||
import { readFile, readdir } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import pg from 'pg';
|
|
||||||
import type { QueryResultRow } from 'pg';
|
|
||||||
import { ulid } from 'ulid';
|
|
||||||
import { rehomeTenant } from '../../scripts/rehome-tenant.mjs';
|
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_REHOME_TEST_URL;
|
|
||||||
const describeDatabase = connectionString ? describe : describe.skip;
|
|
||||||
const confirmation = 'I_ACKNOWLEDGE_DISPOSABLE_PG17_DATABASE';
|
|
||||||
const evidence = {
|
|
||||||
backup: {
|
|
||||||
format: 'pg_dump -Fc',
|
|
||||||
encrypted: true,
|
|
||||||
offHost: true,
|
|
||||||
sha256: 'a'.repeat(64),
|
|
||||||
createdAt: '2026-09-26T08:00:00Z',
|
|
||||||
},
|
|
||||||
restoreDrill: {
|
|
||||||
status: 'passed',
|
|
||||||
backupSha256: 'a'.repeat(64),
|
|
||||||
restoredAt: '2026-09-26T08:30:00Z',
|
|
||||||
verifiedAt: '2026-09-26T08:45:00Z',
|
|
||||||
isolatedDatabase: 'krona_staff_restore_check',
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const tenantTables = [
|
|
||||||
'staff_outbox',
|
|
||||||
'staff_inbox',
|
|
||||||
'staff_idempotency_keys',
|
|
||||||
'staff_audit_log',
|
|
||||||
'staff_employees',
|
|
||||||
'staff_attendance_events',
|
|
||||||
'staff_timesheet_entries',
|
|
||||||
'staff_org_units',
|
|
||||||
'staff_org_unit_terms',
|
|
||||||
'staff_metric_definitions',
|
|
||||||
'staff_daily_metrics',
|
|
||||||
'staff_work_items',
|
|
||||||
'staff_pay_rules',
|
|
||||||
'staff_pay_assignments',
|
|
||||||
'staff_work_sessions',
|
|
||||||
'staff_payroll_runs',
|
|
||||||
'staff_payroll_lines',
|
|
||||||
'staff_payouts',
|
|
||||||
'staff_payroll_ledger',
|
|
||||||
'staff_standalone_employee_access',
|
|
||||||
'staff_professions',
|
|
||||||
'staff_push_subscriptions',
|
|
||||||
'staff_notification_deliveries',
|
|
||||||
'staff_sync_devices',
|
|
||||||
'staff_sync_change_log',
|
|
||||||
'staff_sync_command_inbox',
|
|
||||||
'staff_sync_device_checkpoints',
|
|
||||||
];
|
|
||||||
|
|
||||||
interface IdentityRow extends QueryResultRow {
|
|
||||||
database_name: string;
|
|
||||||
version_num: number;
|
|
||||||
is_superuser: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CountRow extends QueryResultRow {
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoleRow extends QueryResultRow {
|
|
||||||
rolsuper: boolean;
|
|
||||||
rolbypassrls: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PresentRow extends QueryResultRow {
|
|
||||||
present: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableNameRow extends QueryResultRow {
|
|
||||||
relname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RowCountRow extends QueryResultRow {
|
|
||||||
n: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ForeignKeyRow extends QueryResultRow {
|
|
||||||
source_table: string;
|
|
||||||
conname: string;
|
|
||||||
definition: string;
|
|
||||||
convalidated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RehomeAuditRow extends QueryResultRow {
|
|
||||||
action: string;
|
|
||||||
actor_id: string;
|
|
||||||
old_values: { tenantId: string };
|
|
||||||
new_values: { tenantId: string; auditRowsAdded: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrapDisposableDatabase(client: pg.Client) {
|
|
||||||
if (process.env.DATABASE_REHOME_TEST_CONFIRM !== confirmation) {
|
|
||||||
throw new Error(
|
|
||||||
`Set DATABASE_REHOME_TEST_CONFIRM=${confirmation} only for a disposable test database.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const identity = await client.query<IdentityRow>(`
|
|
||||||
SELECT current_database() AS database_name,
|
|
||||||
current_setting('server_version_num')::integer AS version_num,
|
|
||||||
role.rolsuper AS is_superuser
|
|
||||||
FROM pg_roles role WHERE role.rolname = current_user
|
|
||||||
`);
|
|
||||||
const row = identity.rows[0]!;
|
|
||||||
if (
|
|
||||||
!row.database_name.endsWith('_rehome_test') ||
|
|
||||||
row.version_num < 170000 ||
|
|
||||||
!row.is_superuser
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'Integration tests require a disposable database ending _rehome_test on PostgreSQL 17 and a superuser connection.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await client.query<CountRow>(`
|
|
||||||
SELECT count(*)::integer AS count FROM pg_class c
|
|
||||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
||||||
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND left(c.relname, 6) = 'staff_'
|
|
||||||
`);
|
|
||||||
const hasStaffSchema = Number(existing.rows[0]!.count) > 0;
|
|
||||||
const appRole = await client.query<RoleRow>(
|
|
||||||
"SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'",
|
|
||||||
);
|
|
||||||
if (appRole.rowCount === 0) {
|
|
||||||
await client.query(
|
|
||||||
'CREATE ROLE krona_staff_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS',
|
|
||||||
);
|
|
||||||
} else if (appRole.rows[0]!.rolsuper || appRole.rows[0]!.rolbypassrls) {
|
|
||||||
throw new Error('The disposable database app role must be NOSUPERUSER and NOBYPASSRLS.');
|
|
||||||
}
|
|
||||||
if (!hasStaffSchema) {
|
|
||||||
const migrations = (await readdir(path.resolve('migrations')))
|
|
||||||
.filter((name) => name.endsWith('.up.sql'))
|
|
||||||
.sort();
|
|
||||||
for (const migration of migrations) {
|
|
||||||
await client.query(await readFile(path.resolve('migrations', migration), 'utf8'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const checks: Array<{ table: string; present: boolean }> = [];
|
|
||||||
for (const table of tenantTables) {
|
|
||||||
const result = await client.query<PresentRow>(
|
|
||||||
'SELECT to_regclass($1) IS NOT NULL AS present',
|
|
||||||
[`public.${table}`],
|
|
||||||
);
|
|
||||||
checks.push({ table, present: result.rows[0]!.present });
|
|
||||||
}
|
|
||||||
const missing = checks.filter((check) => !check.present);
|
|
||||||
if (missing.length) throw new Error('The disposable database has an incomplete Staff schema.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableNames = await client.query<TableNameRow>(`
|
|
||||||
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
||||||
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
|
|
||||||
AND left(c.relname, 6) = 'staff_'
|
|
||||||
`);
|
|
||||||
for (const { relname } of tableNames.rows) {
|
|
||||||
const count = await client.query<RowCountRow>(
|
|
||||||
`SELECT count(*)::text AS n FROM public."${relname.replaceAll('"', '""')}"`,
|
|
||||||
);
|
|
||||||
if (BigInt(count.rows[0]!.n) !== 0n) {
|
|
||||||
throw new Error('Use an empty disposable database for the tenant-rehome integration test.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listForeignKeyDefinitions(client: pg.Client) {
|
|
||||||
const result = await client.query<ForeignKeyRow>(
|
|
||||||
`
|
|
||||||
SELECT source.relname AS source_table, con.conname,
|
|
||||||
pg_get_constraintdef(con.oid, true) AS definition,
|
|
||||||
con.convalidated
|
|
||||||
FROM pg_constraint con
|
|
||||||
JOIN pg_class source ON source.oid = con.conrelid
|
|
||||||
JOIN pg_namespace n ON n.oid = source.relnamespace
|
|
||||||
WHERE n.nspname = 'public' AND source.relname = ANY($1::text[])
|
|
||||||
AND con.contype = 'f'
|
|
||||||
ORDER BY source.relname, con.conname
|
|
||||||
`,
|
|
||||||
[tenantTables],
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
|
||||||
const client = new pg.Client({
|
|
||||||
connectionString: connectionString!,
|
|
||||||
application_name: 'krona-staff-rehome-integration-test',
|
|
||||||
});
|
|
||||||
let safeToCleanup = false;
|
|
||||||
const source = ulid();
|
|
||||||
const target = ulid();
|
|
||||||
const other = ulid();
|
|
||||||
const actor = ulid();
|
|
||||||
const ids = Object.fromEntries(
|
|
||||||
[
|
|
||||||
'org',
|
|
||||||
'employee',
|
|
||||||
'profession',
|
|
||||||
'metric',
|
|
||||||
'rule',
|
|
||||||
'run',
|
|
||||||
'outbox',
|
|
||||||
'device',
|
|
||||||
'attendance',
|
|
||||||
'timesheet',
|
|
||||||
'term',
|
|
||||||
'dailyMetric',
|
|
||||||
'workItem',
|
|
||||||
'assignment',
|
|
||||||
'session',
|
|
||||||
'line',
|
|
||||||
'payout',
|
|
||||||
'ledger',
|
|
||||||
'access',
|
|
||||||
'subscription',
|
|
||||||
'delivery',
|
|
||||||
'change',
|
|
||||||
'command',
|
|
||||||
'checkpoint',
|
|
||||||
'audit',
|
|
||||||
'inbox',
|
|
||||||
'idempotency',
|
|
||||||
'otherProfession',
|
|
||||||
].map((key) => [key, ulid()]),
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await client.connect();
|
|
||||||
await bootstrapDisposableDatabase(client);
|
|
||||||
safeToCleanup = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (safeToCleanup) {
|
|
||||||
await client.query('DROP TABLE IF EXISTS public.external_rehome_reference;');
|
|
||||||
await client.query(
|
|
||||||
'DROP TRIGGER IF EXISTS rehome_test_unknown_trigger ON public.staff_employees;',
|
|
||||||
);
|
|
||||||
await client.query('DROP FUNCTION IF EXISTS public.rehome_test_trigger_fn();');
|
|
||||||
const tables = tenantTables.map((table) => `public."${table}"`).join(', ');
|
|
||||||
await client.query(`TRUNCATE TABLE ${tables} CASCADE`);
|
|
||||||
}
|
|
||||||
await client.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('moves every tenant table atomically, preserves keys and totals, and audits one added row', async () => {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
try {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'unit_test', 'Fixture unit')`,
|
|
||||||
[ids.org, source],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'role_test', 'Fixture role')`,
|
|
||||||
[ids.profession, source],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_employees (id, tenant_id, profession_id, primary_org_unit_id, personnel_number, display_name)
|
|
||||||
VALUES ($1, $2, $3, $4, 'FIXTURE-1', 'Synthetic fixture')`,
|
|
||||||
[ids.employee, source, ids.profession, ids.org],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_metric_definitions (id, tenant_id, key, label) VALUES ($1, $2, 'fixture_metric', 'Fixture metric')`,
|
|
||||||
[ids.metric, source],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_pay_rules (id, tenant_id, code, version, name, rule, effective_from, created_by)
|
|
||||||
VALUES ($1, $2, 'fixture_rule', 1, 'Fixture rule', '{}'::jsonb, '2026-01-01', $3)`,
|
|
||||||
[ids.rule, source, actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_payroll_runs (id, tenant_id, org_unit_id, period_start, period_end, created_by)
|
|
||||||
VALUES ($1, $2, $3, '2026-01-01', '2026-01-31', $4)`,
|
|
||||||
[ids.run, source, ids.org, actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_outbox
|
|
||||||
(id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at)
|
|
||||||
VALUES ($1, $2, 'fixture.event', 1, 'staff.Employee', $3, 'user', $4, '{}'::jsonb, $5, now())`,
|
|
||||||
[ids.outbox, source, ids.employee, actor, ulid()],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_inbox (tenant_id, event_id, event_type, event_version, correlation_id)
|
|
||||||
VALUES ($1, $2, 'fixture.event', 1, $3)`,
|
|
||||||
[source, ulid(), ulid()],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_idempotency_keys (tenant_id, key, request_hash, expires_at)
|
|
||||||
VALUES ($1, 'fixture-key', 'synthetic-hash', now() + interval '1 day')`,
|
|
||||||
[source],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, correlation_id)
|
|
||||||
VALUES ($1, $2, $3, 'fixture.created', 'fixture', $4)`,
|
|
||||||
[ids.audit, source, actor, ulid()],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_attendance_events
|
|
||||||
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, actor_id, correlation_id)
|
|
||||||
VALUES ($1, $2, $3, 'check_in', 'manual_entry', 'fixture-attendance', now(), $4, $5)`,
|
|
||||||
[ids.attendance, source, ids.employee, actor, ulid()],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by)
|
|
||||||
VALUES ($1, $2, $3, '2026-01-01', $4)`,
|
|
||||||
[ulid(), source, ids.employee, actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_org_unit_terms (id, tenant_id, org_unit_id, effective_from, created_by)
|
|
||||||
VALUES ($1, $2, $3, '2026-01-01', $4)`,
|
|
||||||
[ids.term, source, ids.org, actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, recorded_by)
|
|
||||||
VALUES ($1, $2, $3, '2026-01-01', 'fixture_metric', 123, $4)`,
|
|
||||||
[ids.dailyMetric, source, ids.org, actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, '2026-01-01', 'fixture_item', $5)`,
|
|
||||||
[ids.workItem, source, ids.employee, ids.org, actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_pay_assignments (id, tenant_id, employee_id, pay_rule_id, effective_from, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, '2026-01-01', $5)`,
|
|
||||||
[ids.assignment, source, ids.employee, ids.rule, actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date)
|
|
||||||
VALUES ($1, $2, $3, $4, '2026-01-01')`,
|
|
||||||
[ids.session, source, ids.employee, ids.org],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_payroll_lines (id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id, rule_code, amount_minor)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, '2026-01-01', $6, 'fixture_rule', 12345)`,
|
|
||||||
[ids.line, source, ids.run, ids.employee, ids.org, ids.rule],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_payouts (id, tenant_id, employee_id, paid_on, amount_minor, created_by)
|
|
||||||
VALUES ($1, $2, $3, '2026-01-01', 200, $4)`,
|
|
||||||
[ids.payout, source, ids.employee, actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_payroll_ledger
|
|
||||||
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, correlation_id, created_by)
|
|
||||||
VALUES ($1, $2, $3, '2026-01-01', 'accrual', 12345, 'RUB', 'payroll_run', $4, $5),
|
|
||||||
($6, $2, $3, '2026-01-01', 'adjustment', 50, 'KZT', 'manual', $4, $5)`,
|
|
||||||
[ids.ledger, source, ids.employee, ulid(), actor, ulid()],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_standalone_employee_access
|
|
||||||
(id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, 'synth', $6)`,
|
|
||||||
[ids.access, source, ids.employee, ulid(), 'a'.repeat(64), actor],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth)
|
|
||||||
VALUES ($1, $2, $3, 'https://push.invalid/fixture', 'synthetic-key', 'synthetic-auth')`,
|
|
||||||
[ids.subscription, source, ids.employee],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status)
|
|
||||||
VALUES ($1, $2, $3, $4, 'https://push.invalid/fixture', 'delivered')`,
|
|
||||||
[ids.delivery, source, ids.employee, ids.outbox],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, created_by, org_unit_id)
|
|
||||||
VALUES ($1, $2, 'fixture-device', 'Synthetic device', $3, 'fixture', $4, $5)`,
|
|
||||||
[ids.device, source, 'b'.repeat(64), actor, ids.org],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation)
|
|
||||||
VALUES ($1, $2, 'employee', $3, 'upsert')`,
|
|
||||||
[ids.change, source, ids.employee],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_sync_command_inbox
|
|
||||||
(id, tenant_id, device_id, command_type, idempotency_key, correlation_id, status, created_at)
|
|
||||||
VALUES ($1, $2, $3, 'fixture', 'fixture-command', $4, 'applied', now())`,
|
|
||||||
[ids.command, source, ids.device, ulid()],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq)
|
|
||||||
VALUES ($1, $2, 10)`,
|
|
||||||
[source, ids.device],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'other_fixture', 'Other synthetic role')`,
|
|
||||||
[ids.otherProfession, other],
|
|
||||||
);
|
|
||||||
await client.query('COMMIT');
|
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldRole = await client.query<RoleRow>(
|
|
||||||
"SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'",
|
|
||||||
);
|
|
||||||
expect(oldRole.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
|
|
||||||
await client.query('SET ROLE krona_staff_app');
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }),
|
|
||||||
).rejects.toThrow('privileged database administrator');
|
|
||||||
} finally {
|
|
||||||
await client.query('RESET ROLE');
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialForeignKeys = await listForeignKeyDefinitions(client);
|
|
||||||
const dryRun = await rehomeTenant(client, { mode: 'dry-run', source, target });
|
|
||||||
expect(Object.keys(dryRun.tables)).toHaveLength(tenantTables.length);
|
|
||||||
for (const table of tenantTables) {
|
|
||||||
expect(dryRun.tables[table]).toBe(table === 'staff_payroll_ledger' ? '2' : '1');
|
|
||||||
}
|
|
||||||
|
|
||||||
const occupiedTarget = ulid();
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'occupied_fixture', 'Occupied synthetic target')`,
|
|
||||||
[ulid(), occupiedTarget],
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
rehomeTenant(client, {
|
|
||||||
mode: 'apply',
|
|
||||||
source,
|
|
||||||
target: occupiedTarget,
|
|
||||||
actor,
|
|
||||||
backupEvidence: evidence,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Target tenant already contains staff data');
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE FUNCTION public.rehome_test_trigger_fn() RETURNS trigger
|
|
||||||
LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;
|
|
||||||
CREATE TRIGGER rehome_test_unknown_trigger BEFORE UPDATE ON public.staff_employees
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.rehome_test_trigger_fn();
|
|
||||||
`);
|
|
||||||
await expect(
|
|
||||||
rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }),
|
|
||||||
).rejects.toThrow('Unrecognized user-defined triggers');
|
|
||||||
await client.query(
|
|
||||||
'DROP TRIGGER rehome_test_unknown_trigger ON public.staff_employees; DROP FUNCTION public.rehome_test_trigger_fn();',
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
'CREATE TABLE public.external_rehome_reference (employee_id VARCHAR(26) REFERENCES public.staff_employees(id));',
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }),
|
|
||||||
).rejects.toThrow('foreign key crosses the staff tenant-table boundary');
|
|
||||||
await client.query('DROP TABLE public.external_rehome_reference;');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
rehomeTenant(client, {
|
|
||||||
mode: 'apply',
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
actor,
|
|
||||||
backupEvidence: evidence,
|
|
||||||
correlationId: 'invalid',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Correlation ID must be a valid ULID.');
|
|
||||||
for (const table of tenantTables) {
|
|
||||||
const sourceRows = await client.query<RowCountRow>(
|
|
||||||
`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`,
|
|
||||||
[source],
|
|
||||||
);
|
|
||||||
expect(sourceRows.rows[0]!.n, `${table} survives rollback`).toBe(
|
|
||||||
table === 'staff_payroll_ledger' ? '2' : '1',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys);
|
|
||||||
|
|
||||||
const result = await rehomeTenant(client, {
|
|
||||||
mode: 'apply',
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
actor,
|
|
||||||
backupEvidence: evidence,
|
|
||||||
});
|
|
||||||
expect(result.auditRowsAdded).toBe(1);
|
|
||||||
expect(result.tables.staff_audit_log).toBe('2');
|
|
||||||
expect(result.payrollLedger).toEqual([
|
|
||||||
{ currency: 'KZT', amountMinor: '50' },
|
|
||||||
{ currency: 'RUB', amountMinor: '12345' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
for (const table of tenantTables) {
|
|
||||||
const sourceRows = await client.query<RowCountRow>(
|
|
||||||
`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`,
|
|
||||||
[source],
|
|
||||||
);
|
|
||||||
expect(sourceRows.rows[0]!.n, `${table} source rows`).toBe('0');
|
|
||||||
const targetRows = await client.query<RowCountRow>(
|
|
||||||
`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`,
|
|
||||||
[target],
|
|
||||||
);
|
|
||||||
expect(targetRows.rows[0]!.n, `${table} target rows`).toBe(result.tables[table]);
|
|
||||||
}
|
|
||||||
const untouchedOther = await client.query<RowCountRow>(
|
|
||||||
'SELECT count(*)::text AS n FROM staff_professions WHERE tenant_id = $1',
|
|
||||||
[other],
|
|
||||||
);
|
|
||||||
expect(untouchedOther.rows[0]!.n).toBe('1');
|
|
||||||
expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys);
|
|
||||||
|
|
||||||
await client.query('BEGIN');
|
|
||||||
try {
|
|
||||||
await client.query('SET LOCAL ROLE krona_staff_app');
|
|
||||||
await client.query(
|
|
||||||
"SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)",
|
|
||||||
[target, actor],
|
|
||||||
);
|
|
||||||
const visibleToTarget = await client.query<RowCountRow>(
|
|
||||||
'SELECT count(*)::text AS n FROM staff_employees',
|
|
||||||
);
|
|
||||||
expect(visibleToTarget.rows[0]!.n).toBe('1');
|
|
||||||
await client.query("SELECT set_config('app.tenant_id', $1, true)", [source]);
|
|
||||||
const visibleToOldTenant = await client.query<RowCountRow>(
|
|
||||||
'SELECT count(*)::text AS n FROM staff_employees',
|
|
||||||
);
|
|
||||||
expect(visibleToOldTenant.rows[0]!.n).toBe('0');
|
|
||||||
await client.query('COMMIT');
|
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK').catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rehomeAudit = await client.query<RehomeAuditRow>(
|
|
||||||
`SELECT action, actor_id, old_values, new_values
|
|
||||||
FROM staff_audit_log WHERE tenant_id = $1 AND action = 'tenant.rehomed'`,
|
|
||||||
[target],
|
|
||||||
);
|
|
||||||
expect(rehomeAudit.rows).toHaveLength(1);
|
|
||||||
expect(rehomeAudit.rows[0]!.actor_id).toBe(actor);
|
|
||||||
expect(rehomeAudit.rows[0]!.old_values.tenantId).toBe(source);
|
|
||||||
expect(rehomeAudit.rows[0]!.new_values.tenantId).toBe(target);
|
|
||||||
expect(rehomeAudit.rows[0]!.new_values.auditRowsAdded).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
import pg from 'pg';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { Database } from '../../src/infrastructure/database.js';
|
|
||||||
import { newUlid } from '../../src/core/ids.js';
|
|
||||||
import { SyncService } from '../../src/domain/sync/service.js';
|
|
||||||
import { buildApp } from '../../src/app.js';
|
|
||||||
import { loadConfig } from '../../src/config.js';
|
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_TEST_URL ?? process.env.TEST_DATABASE_URL;
|
|
||||||
const migrationConnectionString =
|
|
||||||
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
|
||||||
const describeDatabase = connectionString ? describe : describe.skip;
|
|
||||||
|
|
||||||
const cleanupTables = [
|
|
||||||
'staff_sync_device_checkpoints',
|
|
||||||
'staff_sync_command_inbox',
|
|
||||||
'staff_sync_change_log',
|
|
||||||
'staff_sync_devices',
|
|
||||||
'staff_timesheet_entries',
|
|
||||||
'staff_work_sessions',
|
|
||||||
'staff_attendance_events',
|
|
||||||
'staff_employees',
|
|
||||||
'staff_org_unit_terms',
|
|
||||||
'staff_org_units',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
describeDatabase('Sync Bootstrap SYNC-C0 (Live PostgreSQL)', () => {
|
|
||||||
const database = new Database(connectionString!);
|
|
||||||
const tenantId = newUlid();
|
|
||||||
const actorId = newUlid();
|
|
||||||
const orgUnitId = newUlid();
|
|
||||||
const employeeId = newUlid();
|
|
||||||
const deviceId = newUlid();
|
|
||||||
const rawToken = `kstaff_dev_${tenantId}_bootstrap_c0_test_token_secret`;
|
|
||||||
const tokenHash = SyncService.hashToken(rawToken);
|
|
||||||
|
|
||||||
const syncService = new SyncService(database, actorId);
|
|
||||||
|
|
||||||
async function cleanupAsMigrator() {
|
|
||||||
const migratorUrl = migrationConnectionString ?? connectionString;
|
|
||||||
if (!migratorUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const migrator = new pg.Client({
|
|
||||||
connectionString: migratorUrl,
|
|
||||||
application_name: 'krona-staff-bootstrap-test-cleanup',
|
|
||||||
});
|
|
||||||
await migrator.connect();
|
|
||||||
try {
|
|
||||||
await migrator.query('BEGIN');
|
|
||||||
try {
|
|
||||||
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
|
|
||||||
for (const table of cleanupTables) {
|
|
||||||
await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenantId]);
|
|
||||||
}
|
|
||||||
await migrator.query('COMMIT');
|
|
||||||
} catch (error) {
|
|
||||||
await migrator.query('ROLLBACK');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await migrator.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await cleanupAsMigrator();
|
|
||||||
await database.tenantTransaction(tenantId, actorId, async (client) => {
|
|
||||||
// 1. Insert real org unit (columns: id, tenant_id, code, name, is_active, created_at, updated_at)
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_org_units (id, tenant_id, code, name, is_active, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, 'bootstrap_point', 'Точка Bootstrap', true, now(), now())`,
|
|
||||||
[orgUnitId, tenantId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Insert employee in staff_employees
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status, primary_org_unit_id, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, 'EMP-BOOT-01', 'Сотрудник Bootstrap', 'active', $3, now(), now())`,
|
|
||||||
[employeeId, tenantId, orgUnitId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Insert sync device for HTTP sync testing
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by)
|
|
||||||
VALUES ($1, $2, 'TERM-BOOT-01', 'Терминал Bootstrap', $3, 'kstaff_dev_...', 'active', ARRAY['attendance:sync']::text[], $4, $5)`,
|
|
||||||
[deviceId, tenantId, tokenHash, orgUnitId, actorId],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
try {
|
|
||||||
await cleanupAsMigrator();
|
|
||||||
} finally {
|
|
||||||
await database.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('syncService.pull(tenantId, { cursor: null }) succeeds without "column status does not exist" and returns org_unit and employee changes', async () => {
|
|
||||||
const result = await syncService.pull(tenantId, { cursor: null });
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.has_more).toBe(false);
|
|
||||||
|
|
||||||
// Verify org_unit change is present with mapped status
|
|
||||||
const orgUnitChange = result.changes.find(
|
|
||||||
(c) => c.entity_type === 'org_unit' && c.entity_id === orgUnitId,
|
|
||||||
);
|
|
||||||
expect(orgUnitChange).toBeDefined();
|
|
||||||
expect(orgUnitChange?.payload).toMatchObject({
|
|
||||||
id: orgUnitId,
|
|
||||||
code: 'bootstrap_point',
|
|
||||||
name: 'Точка Bootstrap',
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify employee change is present
|
|
||||||
const employeeChange = result.changes.find(
|
|
||||||
(c) => c.entity_type === 'employee' && c.entity_id === employeeId,
|
|
||||||
);
|
|
||||||
expect(employeeChange).toBeDefined();
|
|
||||||
expect(employeeChange?.payload).toMatchObject({
|
|
||||||
id: employeeId,
|
|
||||||
personnel_number: 'EMP-BOOT-01',
|
|
||||||
display_name: 'Сотрудник Bootstrap',
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('HTTP /api/v1/staff/sync/pull without cursor returns 200 and includes org_unit and employee in changes', async () => {
|
|
||||||
const config = loadConfig({
|
|
||||||
DATABASE_URL: connectionString!,
|
|
||||||
STANDALONE_TENANT_ID: tenantId,
|
|
||||||
STANDALONE_ACTOR_ID: actorId,
|
|
||||||
LOG_LEVEL: 'silent',
|
|
||||||
NODE_ENV: 'test',
|
|
||||||
});
|
|
||||||
const appDatabase = new Database(connectionString!);
|
|
||||||
const app = await buildApp({ config, database: appDatabase });
|
|
||||||
try {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/staff/sync/pull',
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${rawToken}`,
|
|
||||||
},
|
|
||||||
payload: { cursor: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const body = response.json<{
|
|
||||||
changes: Array<{
|
|
||||||
entity_type: string;
|
|
||||||
entity_id: string;
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
}>;
|
|
||||||
next_cursor: string;
|
|
||||||
has_more: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
body.changes.some((c) => c.entity_type === 'org_unit' && c.entity_id === orgUnitId),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
body.changes.some((c) => c.entity_type === 'employee' && c.entity_id === employeeId),
|
|
||||||
).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
import pg from 'pg';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { Database } from '../../src/infrastructure/database.js';
|
|
||||||
import { newUlid } from '../../src/core/ids.js';
|
|
||||||
import { SyncService } from '../../src/domain/sync/service.js';
|
|
||||||
import type { SyncCommand } from '../../src/domain/sync/types.js';
|
|
||||||
|
|
||||||
const connectionString = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_TEST_URL;
|
|
||||||
const migrationConnectionString =
|
|
||||||
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
|
||||||
const describeDatabase = connectionString ? describe : describe.skip;
|
|
||||||
|
|
||||||
const cleanupTables = [
|
|
||||||
'staff_sync_command_inbox',
|
|
||||||
'staff_sync_change_log',
|
|
||||||
'staff_sync_devices',
|
|
||||||
'staff_work_sessions',
|
|
||||||
'staff_timesheet_entries',
|
|
||||||
'staff_employees',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
|
|
||||||
const database = new Database(connectionString!);
|
|
||||||
const tenantId = newUlid();
|
|
||||||
const actorId = newUlid();
|
|
||||||
const deviceId = newUlid();
|
|
||||||
const employeeId = newUlid();
|
|
||||||
const sessionId = newUlid();
|
|
||||||
const timesheetId = newUlid();
|
|
||||||
const rawToken = `kstaff_dev_${tenantId}_test_rejection_token_secret`;
|
|
||||||
const tokenHash = SyncService.hashToken(rawToken);
|
|
||||||
|
|
||||||
const syncService = new SyncService(database, actorId);
|
|
||||||
|
|
||||||
const testDevice = {
|
|
||||||
id: deviceId,
|
|
||||||
tenant_id: tenantId,
|
|
||||||
device_code: 'TERM-REJECT-TEST',
|
|
||||||
name: 'Rejection Test Terminal',
|
|
||||||
token_hash: tokenHash,
|
|
||||||
token_hint: 'kstaff_dev_...',
|
|
||||||
status: 'active' as const,
|
|
||||||
allowed_scopes: ['attendance:sync'],
|
|
||||||
org_unit_id: null,
|
|
||||||
registered_at: new Date().toISOString(),
|
|
||||||
last_seen_at: null,
|
|
||||||
revoked_at: null,
|
|
||||||
created_by: actorId,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function cleanupAsMigrator() {
|
|
||||||
if (!migrationConnectionString) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const migrator = new pg.Client({
|
|
||||||
connectionString: migrationConnectionString,
|
|
||||||
application_name: 'krona-staff-test-cleanup',
|
|
||||||
});
|
|
||||||
await migrator.connect();
|
|
||||||
try {
|
|
||||||
await migrator.query('BEGIN');
|
|
||||||
try {
|
|
||||||
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
|
|
||||||
for (const table of cleanupTables) {
|
|
||||||
await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenantId]);
|
|
||||||
}
|
|
||||||
await migrator.query('COMMIT');
|
|
||||||
} catch (error) {
|
|
||||||
await migrator.query('ROLLBACK');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await migrator.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await cleanupAsMigrator();
|
|
||||||
await database.tenantTransaction(tenantId, actorId, async (client) => {
|
|
||||||
// Create employee
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status)
|
|
||||||
VALUES ($1, $2, 'EMP-REJ-01', 'Тестовый Сотрудник', 'active')`,
|
|
||||||
[employeeId, tenantId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create sync device
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
||||||
[
|
|
||||||
deviceId,
|
|
||||||
tenantId,
|
|
||||||
testDevice.device_code,
|
|
||||||
testDevice.name,
|
|
||||||
tokenHash,
|
|
||||||
testDevice.token_hint,
|
|
||||||
'active',
|
|
||||||
['attendance:sync'],
|
|
||||||
actorId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
try {
|
|
||||||
await cleanupAsMigrator();
|
|
||||||
} finally {
|
|
||||||
await database.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects upsert_work_session and creates NO records in staff_work_sessions or change log', async () => {
|
|
||||||
// S1 scenario: device attempts to create a confirmed work session with 2880 minutes
|
|
||||||
const command: SyncCommand = {
|
|
||||||
command_type: 'upsert_work_session',
|
|
||||||
schema_version: 1,
|
|
||||||
idempotency_key: 'cmd-live-s1-work-session',
|
|
||||||
correlation_id: newUlid(),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
payload: {
|
|
||||||
id: sessionId,
|
|
||||||
employee_id: employeeId,
|
|
||||||
business_date: '2026-09-26',
|
|
||||||
status: 'confirmed',
|
|
||||||
worked_minutes: 2880,
|
|
||||||
revision: 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = await syncService.push(tenantId, testDevice, [command]);
|
|
||||||
expect(results).toHaveLength(1);
|
|
||||||
expect(results[0]?.status).toBe('rejected');
|
|
||||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
|
||||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
|
||||||
|
|
||||||
// Verify 0 rows in staff_work_sessions
|
|
||||||
const sessionRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
||||||
client.query<{ cnt: number }>(
|
|
||||||
'SELECT count(*)::int as cnt FROM staff_work_sessions WHERE tenant_id = $1',
|
|
||||||
[tenantId],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(sessionRes.rows[0]?.cnt).toBe(0);
|
|
||||||
|
|
||||||
// Verify 0 rows in staff_sync_change_log for work_session
|
|
||||||
const changeRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
||||||
client.query<{ cnt: number }>(
|
|
||||||
"SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'work_session'",
|
|
||||||
[tenantId],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(changeRes.rows[0]?.cnt).toBe(0);
|
|
||||||
|
|
||||||
// Verify recorded in staff_sync_command_inbox as rejected
|
|
||||||
const inboxRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
||||||
client.query<{ status: string; error_code: string }>(
|
|
||||||
'SELECT status, error_code FROM staff_sync_command_inbox WHERE tenant_id = $1 AND idempotency_key = $2',
|
|
||||||
[tenantId, command.idempotency_key],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(inboxRes.rows[0]?.status).toBe('rejected');
|
|
||||||
expect(inboxRes.rows[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects upsert_timesheet_entry and creates NO records in staff_timesheet_entries or change log', async () => {
|
|
||||||
const command: SyncCommand = {
|
|
||||||
command_type: 'upsert_timesheet_entry',
|
|
||||||
schema_version: 1,
|
|
||||||
idempotency_key: 'cmd-live-timesheet-entry',
|
|
||||||
correlation_id: newUlid(),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
payload: {
|
|
||||||
id: timesheetId,
|
|
||||||
employee_id: employeeId,
|
|
||||||
work_date: '2026-09-26',
|
|
||||||
minutes_worked: 480,
|
|
||||||
status: 'approved',
|
|
||||||
revision: 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = await syncService.push(tenantId, testDevice, [command]);
|
|
||||||
expect(results).toHaveLength(1);
|
|
||||||
expect(results[0]?.status).toBe('rejected');
|
|
||||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
|
||||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
|
||||||
|
|
||||||
// Verify 0 rows in staff_timesheet_entries
|
|
||||||
const tsRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
||||||
client.query<{ cnt: number }>(
|
|
||||||
'SELECT count(*)::int as cnt FROM staff_timesheet_entries WHERE tenant_id = $1',
|
|
||||||
[tenantId],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(tsRes.rows[0]?.cnt).toBe(0);
|
|
||||||
|
|
||||||
// Verify 0 rows in staff_sync_change_log for timesheet_entry
|
|
||||||
const changeRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
||||||
client.query<{ cnt: number }>(
|
|
||||||
"SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'timesheet_entry'",
|
|
||||||
[tenantId],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(changeRes.rows[0]?.cnt).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,39 +1,16 @@
|
||||||
import pg from 'pg';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { Database } from '../../src/infrastructure/database.js';
|
import { Database } from '../../src/infrastructure/database.js';
|
||||||
import { newUlid } from '../../src/core/ids.js';
|
import { newUlid, parseUlid } from '../../src/core/ids.js';
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_TEST_URL;
|
const connectionString = process.env.DATABASE_TEST_URL;
|
||||||
const describeDatabase = connectionString ? describe : describe.skip;
|
const describeDatabase = connectionString ? describe : describe.skip;
|
||||||
|
|
||||||
// Роль приложения намеренно не имеет DELETE на staff_employees и ряде других таблиц,
|
|
||||||
// поэтому очистка идёт соединением роли мигратора (владельца таблиц). FORCE RLS действует
|
|
||||||
// и на владельца, поэтому внутри транзакции выставляется app.tenant_id.
|
|
||||||
const migrationConnectionString =
|
|
||||||
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
|
||||||
const cleanupTables = [
|
|
||||||
'staff_biometric_consents',
|
|
||||||
'staff_batch_notifications',
|
|
||||||
'staff_payroll_batch_lines',
|
|
||||||
'staff_payroll_batches',
|
|
||||||
'staff_access_grants',
|
|
||||||
'staff_sync_device_checkpoints',
|
|
||||||
'staff_sync_command_inbox',
|
|
||||||
'staff_sync_change_log',
|
|
||||||
'staff_sync_devices',
|
|
||||||
'staff_notification_deliveries',
|
|
||||||
'staff_push_subscriptions',
|
|
||||||
'staff_outbox',
|
|
||||||
'staff_professions',
|
|
||||||
'staff_employees',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
describeDatabase('PostgreSQL tenant isolation', () => {
|
describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
const database = new Database(connectionString!);
|
const database = new Database(connectionString!);
|
||||||
const tenantA = newUlid();
|
const tenantA = parseUlid('01J0000000000000000000000A');
|
||||||
const tenantB = newUlid();
|
const tenantB = parseUlid('01J0000000000000000000000B');
|
||||||
const actorA = newUlid();
|
const actorA = parseUlid('01J0000000000000000000001A');
|
||||||
const actorB = newUlid();
|
const actorB = parseUlid('01J0000000000000000000001B');
|
||||||
const eventId = newUlid();
|
const eventId = newUlid();
|
||||||
const professionId = newUlid();
|
const professionId = newUlid();
|
||||||
const employeeIdA = newUlid();
|
const employeeIdA = newUlid();
|
||||||
|
|
@ -43,51 +20,26 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
const changeIdA = newUlid();
|
const changeIdA = newUlid();
|
||||||
const commandIdA = newUlid();
|
const commandIdA = newUlid();
|
||||||
|
|
||||||
async function cleanupAsMigrator() {
|
|
||||||
if (!migrationConnectionString) {
|
|
||||||
throw new Error(
|
|
||||||
'DATABASE_TEST_MIGRATION_URL (или DATABASE_MIGRATION_URL) обязателен вместе с DATABASE_TEST_URL: очистка выполняется ролью мигратора',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const migrator = new pg.Client({
|
|
||||||
connectionString: migrationConnectionString,
|
|
||||||
application_name: 'krona-staff-test-cleanup',
|
|
||||||
});
|
|
||||||
await migrator.connect();
|
|
||||||
try {
|
|
||||||
for (const tenant of [tenantA, tenantB]) {
|
|
||||||
await migrator.query('BEGIN');
|
|
||||||
try {
|
|
||||||
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenant]);
|
|
||||||
for (const table of cleanupTables) {
|
|
||||||
await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenant]);
|
|
||||||
}
|
|
||||||
await migrator.query('COMMIT');
|
|
||||||
} catch (error) {
|
|
||||||
await migrator.query('ROLLBACK');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await migrator.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const role = await database.pool.query(
|
const role = await database.pool.query(
|
||||||
'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user',
|
'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user',
|
||||||
);
|
);
|
||||||
expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
|
expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
|
||||||
// Остатки прошлого упавшего прогона иначе ломают уникальные ключи (EMP-PUSH-1, DEV-A).
|
|
||||||
await cleanupAsMigrator();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
try {
|
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||||
await cleanupAsMigrator();
|
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [tenantA]);
|
||||||
} finally {
|
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]);
|
||||||
|
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantA]);
|
||||||
|
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantA]);
|
||||||
|
await client.query('DELETE FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]);
|
||||||
|
await client.query('DELETE FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]);
|
||||||
|
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]);
|
||||||
|
await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]);
|
||||||
|
await client.query('DELETE FROM staff_employees WHERE id = $1', [employeeIdA]);
|
||||||
|
});
|
||||||
await database.close();
|
await database.close();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents reads and writes across two tenants', async () => {
|
it('prevents reads and writes across two tenants', async () => {
|
||||||
|
|
@ -301,9 +253,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||||
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [
|
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]),
|
||||||
deviceIdA,
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
expect(cpVisibleToB.rowCount).toBe(0);
|
expect(cpVisibleToB.rowCount).toBe(0);
|
||||||
|
|
||||||
|
|
@ -316,56 +266,5 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
|
|
||||||
// Access grants tenant isolation
|
|
||||||
const grantIdA = newUlid();
|
|
||||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by)
|
|
||||||
VALUES ($1, $2, 'point_manager', 'Manager A', '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hint', $3)`,
|
|
||||||
[grantIdA, tenantA, actorA],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const grantVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
|
||||||
client.query('SELECT id FROM staff_access_grants WHERE id = $1', [grantIdA]),
|
|
||||||
);
|
|
||||||
expect(grantVisibleToB.rowCount).toBe(0);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
|
||||||
client.query(
|
|
||||||
`INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by)
|
|
||||||
VALUES ($1, $2, 'point_manager', 'Manager B Fail', 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', 'hint', $3)`,
|
|
||||||
[newUlid(), tenantA, actorB],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
// Biometric consents tenant isolation
|
|
||||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by)
|
|
||||||
VALUES ($1, $2, 'granted', 'doc-ref-a', $3)`,
|
|
||||||
[tenantA, employeeIdA, actorA],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const consentVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
|
||||||
client.query('SELECT status FROM staff_biometric_consents WHERE employee_id = $1', [
|
|
||||||
employeeIdA,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(consentVisibleToB.rowCount).toBe(0);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
|
||||||
client.query(
|
|
||||||
`INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by)
|
|
||||||
VALUES ($1, $2, 'revoked', 'doc-ref-b', $3)`,
|
|
||||||
[tenantA, employeeIdA, actorB],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { loadConfig } from '../../src/config.js';
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
DATABASE_URL: 'postgres://localhost/staff',
|
DATABASE_URL: 'postgres://localhost/staff',
|
||||||
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -18,33 +18,6 @@ describe('runtime modes', () => {
|
||||||
expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow(
|
expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow(
|
||||||
/standalone is not allowed/,
|
/standalone is not allowed/,
|
||||||
));
|
));
|
||||||
it('requires a standalone tenant ID', () => {
|
|
||||||
expect(() => loadConfig({ ...base, STANDALONE_TENANT_ID: undefined })).toThrow(
|
|
||||||
/STANDALONE_TENANT_ID/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('allows the placeholder only in development on loopback', () => {
|
|
||||||
expect(
|
|
||||||
loadConfig({
|
|
||||||
...base,
|
|
||||||
NODE_ENV: 'development',
|
|
||||||
HOST: '127.0.0.1',
|
|
||||||
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
|
||||||
}).STANDALONE_TENANT_ID,
|
|
||||||
).toBe('01J00000000000000000000000');
|
|
||||||
});
|
|
||||||
it('rejects the placeholder in production and development on a non-loopback host', () => {
|
|
||||||
const placeholder = {
|
|
||||||
...base,
|
|
||||||
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
|
||||||
};
|
|
||||||
expect(() => loadConfig({ ...placeholder, NODE_ENV: 'production' })).toThrow(
|
|
||||||
/allowed only in development on loopback/,
|
|
||||||
);
|
|
||||||
expect(() => loadConfig({ ...placeholder, NODE_ENV: 'development', HOST: '0.0.0.0' })).toThrow(
|
|
||||||
/allowed only in development on loopback/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('treats empty optional platform variables as absent in compose', () =>
|
it('treats empty optional platform variables as absent in compose', () =>
|
||||||
expect(
|
expect(
|
||||||
loadConfig({
|
loadConfig({
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
parseTenantRehomeArgs,
|
|
||||||
validateBackupEvidenceObject,
|
|
||||||
} from '../../scripts/rehome-tenant.mjs';
|
|
||||||
|
|
||||||
const source = '01J00000000000000000000001';
|
|
||||||
const target = '01J00000000000000000000002';
|
|
||||||
const actor = '01J00000000000000000000003';
|
|
||||||
const sha256 = 'a'.repeat(64);
|
|
||||||
|
|
||||||
describe('tenant rehome argument and evidence validation', () => {
|
|
||||||
it('defaults to read-only dry-run and accepts the published CLI aliases', () => {
|
|
||||||
expect(parseTenantRehomeArgs(['--from', source, '--to', target, '--json'])).toEqual({
|
|
||||||
mode: 'dry-run',
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
json: true,
|
|
||||||
});
|
|
||||||
expect(parseTenantRehomeArgs(['--dry-run', '--source', source, '--target', target]).mode).toBe(
|
|
||||||
'dry-run',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires backup evidence for every apply invocation', () => {
|
|
||||||
expect(() =>
|
|
||||||
parseTenantRehomeArgs(['--apply', '--from', source, '--to', target, '--actor', actor]),
|
|
||||||
).toThrow('--backup-evidence is required');
|
|
||||||
expect(() =>
|
|
||||||
parseTenantRehomeArgs(['--from', source, '--source', source, '--to', target]),
|
|
||||||
).toThrow('Duplicate option for source.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts only evidence with encrypted off-host backup and a matching successful restore', () => {
|
|
||||||
const fullEvidence = {
|
|
||||||
backup: {
|
|
||||||
format: 'pg_dump -Fc' as const,
|
|
||||||
encrypted: true as const,
|
|
||||||
offHost: true as const,
|
|
||||||
sha256,
|
|
||||||
createdAt: '2026-09-26T08:00:00Z',
|
|
||||||
},
|
|
||||||
restoreDrill: {
|
|
||||||
status: 'passed' as const,
|
|
||||||
backupSha256: sha256,
|
|
||||||
restoredAt: '2026-09-26T08:30:00Z',
|
|
||||||
verifiedAt: '2026-09-26T08:45:00Z',
|
|
||||||
isolatedDatabase: 'krona_staff_restore_check',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(validateBackupEvidenceObject(fullEvidence)).toEqual(fullEvidence);
|
|
||||||
expect(() =>
|
|
||||||
validateBackupEvidenceObject({
|
|
||||||
backupSha256: sha256,
|
|
||||||
backupCreatedAt: '2026-09-26T08:00:00Z',
|
|
||||||
restoreVerifiedAt: '2026-09-26T08:45:00Z',
|
|
||||||
}),
|
|
||||||
).toThrow('does not attest an encrypted off-host custom dump');
|
|
||||||
expect(() =>
|
|
||||||
validateBackupEvidenceObject({
|
|
||||||
...fullEvidence,
|
|
||||||
restoreDrill: { ...fullEvidence.restoreDrill, backupSha256: 'b'.repeat(64) },
|
|
||||||
}),
|
|
||||||
).toThrow('does not attest an encrypted off-host custom dump');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -8,9 +8,7 @@ import type { SyncCommand } from '../../src/domain/sync/types.js';
|
||||||
import { buildApp } from '../../src/app.js';
|
import { buildApp } from '../../src/app.js';
|
||||||
import { loadConfig } from '../../src/config.js';
|
import { loadConfig } from '../../src/config.js';
|
||||||
|
|
||||||
function createMockDb(
|
function createMockDb(queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }) {
|
||||||
queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number },
|
|
||||||
) {
|
|
||||||
const queries: Array<{ sql: string; params: unknown[] }> = [];
|
const queries: Array<{ sql: string; params: unknown[] }> = [];
|
||||||
const query = vi.fn(async (sql: string, params: unknown[] = []) => {
|
const query = vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||||
queries.push({ sql, params });
|
queries.push({ sql, params });
|
||||||
|
|
@ -107,9 +105,7 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
|
||||||
expect(device.tenant_id).toBe(tenantA);
|
expect(device.tenant_id).toBe(tenantA);
|
||||||
expect(device.status).toBe('active');
|
expect(device.status).toBe('active');
|
||||||
expect(
|
expect(
|
||||||
queries.some(
|
queries.some((q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at')),
|
||||||
(q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at'),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -191,9 +187,7 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new SyncService(database, systemActorId);
|
const service = new SyncService(database, systemActorId);
|
||||||
await expect(
|
await expect(service.authenticateDevice(rawToken, tenantA, 'attendance:sync')).rejects.toMatchObject({
|
||||||
service.authenticateDevice(rawToken, tenantA, 'attendance:sync'),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: 'SCOPE_INSUFFICIENT',
|
code: 'SCOPE_INSUFFICIENT',
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
});
|
});
|
||||||
|
|
@ -303,15 +297,25 @@ describe('Sync Push & Idempotency', () => {
|
||||||
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(false);
|
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects upsert_work_session command with COMMAND_NOT_SUPPORTED', async () => {
|
it('returns structured conflict on revision mismatch for work_session', async () => {
|
||||||
const sessionId = newUlid();
|
const sessionId = newUlid();
|
||||||
const { database } = createMockDb();
|
|
||||||
|
const { database } = createMockDb((sql) => {
|
||||||
|
if (sql.includes('SELECT status, result, conflict')) {
|
||||||
|
return { rows: [] }; // Not in inbox
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id, revision, status FROM staff_work_sessions')) {
|
||||||
|
// Server has revision 3
|
||||||
|
return { rows: [{ id: sessionId, revision: 3, status: 'confirmed' }] };
|
||||||
|
}
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
|
||||||
const service = new SyncService(database, systemActorId);
|
const service = new SyncService(database, systemActorId);
|
||||||
const command: SyncCommand = {
|
const command: SyncCommand = {
|
||||||
command_type: 'upsert_work_session',
|
command_type: 'upsert_work_session',
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
idempotency_key: 'cmd-session-reject',
|
idempotency_key: 'cmd-session-conflict',
|
||||||
correlation_id: newUlid(),
|
correlation_id: newUlid(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
payload: {
|
payload: {
|
||||||
|
|
@ -319,28 +323,43 @@ describe('Sync Push & Idempotency', () => {
|
||||||
employee_id: employeeId,
|
employee_id: employeeId,
|
||||||
org_unit_id: newUlid(),
|
org_unit_id: newUlid(),
|
||||||
business_date: '2026-09-26',
|
business_date: '2026-09-26',
|
||||||
revision: 1,
|
revision: 2, // Client expects revision 2, but server has 3
|
||||||
worked_minutes: 480,
|
worked_minutes: 480,
|
||||||
status: 'confirmed',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = await service.push(tenantId, testDevice, [command]);
|
const results = await service.push(tenantId, testDevice, [command]);
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
expect(results[0]?.status).toBe('rejected');
|
expect(results[0]?.status).toBe('conflict');
|
||||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
expect(results[0]?.conflict).toEqual({
|
||||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
conflict_code: 'REVISION_MISMATCH',
|
||||||
|
entity_type: 'work_session',
|
||||||
|
entity_id: sessionId,
|
||||||
|
expected_revision: 2,
|
||||||
|
actual_revision: 3,
|
||||||
|
safe_description: 'Ревизия рабочей смены на сервере изменилась',
|
||||||
|
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects upsert_timesheet_entry command with COMMAND_NOT_SUPPORTED', async () => {
|
it('returns structured conflict on revision mismatch for timesheet_entry', async () => {
|
||||||
const entryId = newUlid();
|
const entryId = newUlid();
|
||||||
const { database } = createMockDb();
|
|
||||||
|
const { database } = createMockDb((sql) => {
|
||||||
|
if (sql.includes('SELECT status, result, conflict')) {
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT id, revision, status FROM staff_timesheet_entries')) {
|
||||||
|
return { rows: [{ id: entryId, revision: 4, status: 'approved' }] };
|
||||||
|
}
|
||||||
|
return { rows: [] };
|
||||||
|
});
|
||||||
|
|
||||||
const service = new SyncService(database, systemActorId);
|
const service = new SyncService(database, systemActorId);
|
||||||
const command: SyncCommand = {
|
const command: SyncCommand = {
|
||||||
command_type: 'upsert_timesheet_entry',
|
command_type: 'upsert_timesheet_entry',
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
idempotency_key: 'cmd-ts-reject',
|
idempotency_key: 'cmd-ts-conflict',
|
||||||
correlation_id: newUlid(),
|
correlation_id: newUlid(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
payload: {
|
payload: {
|
||||||
|
|
@ -348,92 +367,22 @@ describe('Sync Push & Idempotency', () => {
|
||||||
employee_id: employeeId,
|
employee_id: employeeId,
|
||||||
work_date: '2026-09-26',
|
work_date: '2026-09-26',
|
||||||
minutes_worked: 500,
|
minutes_worked: 500,
|
||||||
revision: 1,
|
revision: 1, // Mismatch: expects 1, server has 4
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = await service.push(tenantId, testDevice, [command]);
|
const results = await service.push(tenantId, testDevice, [command]);
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
expect(results[0]?.status).toBe('rejected');
|
expect(results[0]?.status).toBe('conflict');
|
||||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
expect(results[0]?.conflict).toEqual({
|
||||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
conflict_code: 'REVISION_MISMATCH',
|
||||||
|
entity_type: 'timesheet_entry',
|
||||||
|
entity_id: entryId,
|
||||||
|
expected_revision: 1,
|
||||||
|
actual_revision: 4,
|
||||||
|
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
||||||
|
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not write to staff_work_sessions, staff_timesheet_entries, or staff_sync_change_log on rejected shift commands', async () => {
|
|
||||||
const { database, queries } = createMockDb();
|
|
||||||
|
|
||||||
const service = new SyncService(database, systemActorId);
|
|
||||||
const commands: SyncCommand[] = [
|
|
||||||
{
|
|
||||||
command_type: 'upsert_work_session',
|
|
||||||
schema_version: 1,
|
|
||||||
idempotency_key: 'cmd-session-no-write',
|
|
||||||
correlation_id: newUlid(),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
payload: {
|
|
||||||
id: newUlid(),
|
|
||||||
employee_id: employeeId,
|
|
||||||
org_unit_id: newUlid(),
|
|
||||||
business_date: '2026-09-26',
|
|
||||||
status: 'confirmed',
|
|
||||||
worked_minutes: 2880,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command_type: 'upsert_timesheet_entry',
|
|
||||||
schema_version: 1,
|
|
||||||
idempotency_key: 'cmd-ts-no-write',
|
|
||||||
correlation_id: newUlid(),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
payload: {
|
|
||||||
id: newUlid(),
|
|
||||||
employee_id: employeeId,
|
|
||||||
work_date: '2026-09-26',
|
|
||||||
minutes_worked: 500,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await service.push(tenantId, testDevice, commands);
|
|
||||||
expect(results).toHaveLength(2);
|
|
||||||
expect(results.every((r) => r.status === 'rejected')).toBe(true);
|
|
||||||
|
|
||||||
// Verify nothing is written to staff_work_sessions
|
|
||||||
expect(
|
|
||||||
queries.some(
|
|
||||||
(q) =>
|
|
||||||
q.sql.toLowerCase().includes('staff_work_sessions') &&
|
|
||||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
// Verify nothing is written to staff_timesheet_entries
|
|
||||||
expect(
|
|
||||||
queries.some(
|
|
||||||
(q) =>
|
|
||||||
q.sql.toLowerCase().includes('staff_timesheet_entries') &&
|
|
||||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
// Verify nothing is written to staff_sync_change_log
|
|
||||||
expect(
|
|
||||||
queries.some(
|
|
||||||
(q) =>
|
|
||||||
q.sql.toLowerCase().includes('staff_sync_change_log') &&
|
|
||||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
// Verify commands ARE saved to inbox with status 'rejected'
|
|
||||||
const inboxInserts = queries.filter((q) =>
|
|
||||||
q.sql.includes('INSERT INTO staff_sync_command_inbox'),
|
|
||||||
);
|
|
||||||
expect(inboxInserts).toHaveLength(2);
|
|
||||||
for (const insert of inboxInserts) {
|
|
||||||
expect(insert.params).toContain('rejected');
|
|
||||||
expect(insert.params).toContain('COMMAND_NOT_SUPPORTED');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enforces batch size limits and payload size limits', async () => {
|
it('enforces batch size limits and payload size limits', async () => {
|
||||||
|
|
@ -519,7 +468,7 @@ describe('Sync Pull & Bootstrap Data Safety', () => {
|
||||||
const orgId = newUlid();
|
const orgId = newUlid();
|
||||||
const profId = newUlid();
|
const profId = newUlid();
|
||||||
|
|
||||||
const { database, queries } = createMockDb((sql) => {
|
const { database } = createMockDb((sql) => {
|
||||||
if (sql.includes('MAX(cursor_seq)')) {
|
if (sql.includes('MAX(cursor_seq)')) {
|
||||||
return { rows: [{ max_seq: '50' }] };
|
return { rows: [{ max_seq: '50' }] };
|
||||||
}
|
}
|
||||||
|
|
@ -553,23 +502,6 @@ describe('Sync Pull & Bootstrap Data Safety', () => {
|
||||||
expect(result.next_cursor).toBe(encodeCursor(tenantId, 50n));
|
expect(result.next_cursor).toBe(encodeCursor(tenantId, 50n));
|
||||||
expect(result.has_more).toBe(false);
|
expect(result.has_more).toBe(false);
|
||||||
|
|
||||||
// Verify staff_org_units query uses CASE expression for status
|
|
||||||
const orgQuery = queries.find((q) => q.sql.includes('FROM staff_org_units'));
|
|
||||||
expect(orgQuery).toBeDefined();
|
|
||||||
expect(orgQuery!.sql).toMatch(
|
|
||||||
/CASE[\s\S]+WHEN deleted_at IS NOT NULL THEN 'archived'[\s\S]+WHEN NOT is_active THEN 'inactive'[\s\S]+ELSE 'active'[\s\S]+END AS status/,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify SafeOrgUnitPayload
|
|
||||||
const orgChange = result.changes.find((c) => c.entity_type === 'org_unit');
|
|
||||||
expect(orgChange).toBeDefined();
|
|
||||||
expect(orgChange!.payload).toEqual({
|
|
||||||
id: orgId,
|
|
||||||
code: 'KITCHEN',
|
|
||||||
name: 'Кухня',
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify employee payload has NO sensitive fields
|
// Verify employee payload has NO sensitive fields
|
||||||
const empChange = result.changes.find((c) => c.entity_type === 'employee');
|
const empChange = result.changes.find((c) => c.entity_type === 'employee');
|
||||||
expect(empChange).toBeDefined();
|
expect(empChange).toBeDefined();
|
||||||
|
|
@ -584,56 +516,6 @@ describe('Sync Pull & Bootstrap Data Safety', () => {
|
||||||
expect(payload).not.toHaveProperty('template');
|
expect(payload).not.toHaveProperty('template');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bootstrap sync selects staff_org_units status via CASE expression and forms SafeOrgUnitPayload', async () => {
|
|
||||||
const orgActiveId = newUlid();
|
|
||||||
const orgInactiveId = newUlid();
|
|
||||||
const orgArchivedId = newUlid();
|
|
||||||
|
|
||||||
const { database, queries } = createMockDb((sql) => {
|
|
||||||
if (sql.includes('MAX(cursor_seq)')) {
|
|
||||||
return { rows: [{ max_seq: '10' }] };
|
|
||||||
}
|
|
||||||
if (sql.includes('FROM staff_org_units')) {
|
|
||||||
return {
|
|
||||||
rows: [
|
|
||||||
{ id: orgActiveId, code: 'point_active', name: 'Точка Активная', status: 'active' },
|
|
||||||
{
|
|
||||||
id: orgInactiveId,
|
|
||||||
code: 'point_inactive',
|
|
||||||
name: 'Точка Неактивная',
|
|
||||||
status: 'inactive',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: orgArchivedId,
|
|
||||||
code: 'point_archived',
|
|
||||||
name: 'Точка В Архиве',
|
|
||||||
status: 'archived',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { rows: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
const service = new SyncService(database, systemActorId);
|
|
||||||
const result = await service.pull(tenantId, { cursor: null, entity_types: ['org_unit'] });
|
|
||||||
|
|
||||||
const orgQuery = queries.find((q) => q.sql.includes('FROM staff_org_units'));
|
|
||||||
expect(orgQuery).toBeDefined();
|
|
||||||
expect(orgQuery!.sql).toContain('CASE');
|
|
||||||
expect(orgQuery!.sql).toContain("WHEN deleted_at IS NOT NULL THEN 'archived'");
|
|
||||||
expect(orgQuery!.sql).toContain("WHEN NOT is_active THEN 'inactive'");
|
|
||||||
expect(orgQuery!.sql).toContain("ELSE 'active'");
|
|
||||||
expect(orgQuery!.sql).toContain('END AS status');
|
|
||||||
|
|
||||||
expect(result.changes).toHaveLength(3);
|
|
||||||
expect(result.changes.map((c) => c.payload)).toEqual([
|
|
||||||
{ id: orgActiveId, code: 'point_active', name: 'Точка Активная', status: 'active' },
|
|
||||||
{ id: orgInactiveId, code: 'point_inactive', name: 'Точка Неактивная', status: 'inactive' },
|
|
||||||
{ id: orgArchivedId, code: 'point_archived', name: 'Точка В Архиве', status: 'archived' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('incremental pull returns changes strictly after cursor', async () => {
|
it('incremental pull returns changes strictly after cursor', async () => {
|
||||||
const cursor = encodeCursor(tenantId, 10n);
|
const cursor = encodeCursor(tenantId, 10n);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue