Compare commits
22 commits
grok/e2e-h
...
codex/boot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d200851b | ||
|
|
0910afd54d | ||
|
|
94f17c78cf | ||
|
|
e6dd4b743c | ||
|
|
89621ea069 | ||
|
|
29428cee08 | ||
|
|
94308e9e5c | ||
|
|
991a068750 | ||
|
|
2a2b0bde86 | ||
|
|
8c22ce83b4 | ||
|
|
4fc053850f | ||
|
|
50ee365882 | ||
|
|
df39565b68 | ||
|
|
23a6f5ca80 | ||
|
|
7cf5142678 | ||
|
|
53737f8c36 | ||
|
|
314525aaa1 | ||
| f7d3c73f1f | |||
|
|
24cc18de0e | ||
|
|
ef9e00ce80 | ||
|
|
b283736312 | ||
| b0243d5001 |
75 changed files with 5829 additions and 709 deletions
|
|
@ -11,5 +11,6 @@ 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
|
||||||
STANDALONE_TENANT_ID=01J00000000000000000000000
|
# Set this to the organization's tenant ID from Krona Business Platform.
|
||||||
|
STANDALONE_TENANT_ID=
|
||||||
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
```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
|
||||||
```
|
```
|
||||||
|
|
@ -22,7 +24,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/ROADMAP.md](docs/ROADMAP.md).
|
Архитектурные решения: [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).
|
||||||
|
|
||||||
## Расчётный контур
|
## Расчётный контур
|
||||||
|
|
||||||
|
|
|
||||||
207
api/openapi.yaml
207
api/openapi.yaml
|
|
@ -74,6 +74,26 @@ 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: Получить доступную пользователю сводку посещаемости и табеля
|
||||||
|
|
@ -311,6 +331,30 @@ 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: Получить зарплатный профиль сотрудника
|
||||||
|
|
@ -372,6 +416,15 @@ 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: Получить доступные торговые точки
|
||||||
|
|
@ -476,6 +529,92 @@ 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: Рассчитать зарплату за период
|
||||||
|
|
@ -567,6 +706,20 @@ 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: Идемпотентно импортировать локально разобранную книгу владельца
|
||||||
|
|
@ -691,7 +844,18 @@ paths:
|
||||||
properties:
|
properties:
|
||||||
device:
|
device:
|
||||||
type: object
|
type: object
|
||||||
required: [id, tenant_id, device_code, name, token_hint, status, allowed_scopes, registered_at, created_by]
|
required:
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
device_code,
|
||||||
|
name,
|
||||||
|
token_hint,
|
||||||
|
status,
|
||||||
|
allowed_scopes,
|
||||||
|
registered_at,
|
||||||
|
created_by,
|
||||||
|
]
|
||||||
properties:
|
properties:
|
||||||
id: { $ref: '#/components/schemas/Ulid' }
|
id: { $ref: '#/components/schemas/Ulid' }
|
||||||
tenant_id: { $ref: '#/components/schemas/Ulid' }
|
tenant_id: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
|
@ -803,7 +967,8 @@ paths:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
required: [cursor, entity_type, entity_id, operation, revision, payload, created_at]
|
required:
|
||||||
|
[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 }
|
||||||
|
|
@ -837,7 +1002,15 @@ paths:
|
||||||
maxItems: 100
|
maxItems: 100
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
required: [command_type, schema_version, idempotency_key, correlation_id, created_at, payload]
|
required:
|
||||||
|
[
|
||||||
|
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 }
|
schema_version: { type: integer }
|
||||||
|
|
@ -874,7 +1047,16 @@ paths:
|
||||||
conflict:
|
conflict:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: object
|
- type: object
|
||||||
required: [conflict_code, entity_type, entity_id, expected_revision, actual_revision, safe_description, client_action]
|
required:
|
||||||
|
[
|
||||||
|
conflict_code,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
expected_revision,
|
||||||
|
actual_revision,
|
||||||
|
safe_description,
|
||||||
|
client_action,
|
||||||
|
]
|
||||||
properties:
|
properties:
|
||||||
conflict_code: { type: string, enum: [REVISION_MISMATCH] }
|
conflict_code: { type: string, enum: [REVISION_MISMATCH] }
|
||||||
entity_type: { type: string }
|
entity_type: { type: string }
|
||||||
|
|
@ -885,6 +1067,9 @@ 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' }
|
||||||
|
|
@ -925,6 +1110,20 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@
|
||||||
В операционных системах Windows (начиная с Windows Vista / Server 2008 и включая Windows 10/11) службы выполняются в изолированной сессии **Session 0** без интерактивного рабочего стола.
|
В операционных системах Windows (начиная с Windows Vista / Server 2008 и включая Windows 10/11) службы выполняются в изолированной сессии **Session 0** без интерактивного рабочего стола.
|
||||||
|
|
||||||
### Результаты анализа ZKFinger SDK 5.3 и драйвера SLK20R в Session 0:
|
### Результаты анализа ZKFinger SDK 5.3 и драйвера SLK20R в Session 0:
|
||||||
|
|
||||||
1. Пользовательская библиотека `libzkfp.dll` и драйвер `slk20r.sys` при инициализации используют оконные сообщения Win32 (`WM_DEVICECHANGE`) и оконные дескрипторы для отслеживания PnP-событий USB, а также дескрипторы интерфейсов устройств, привязанные к интерактивным пользовательским сессиям.
|
1. Пользовательская библиотека `libzkfp.dll` и драйвер `slk20r.sys` при инициализации используют оконные сообщения Win32 (`WM_DEVICECHANGE`) и оконные дескрипторы для отслеживания PnP-событий USB, а также дескрипторы интерфейсов устройств, привязанные к интерактивным пользовательским сессиям.
|
||||||
2. При вызове `zkfp2.Init()` в Session 0 драйвер сообщает 0 подключённых устройств (`GetDeviceCount() == 0`) либо завершается с кодом ошибки дескриптора (-1 / -7).
|
2. При вызове `zkfp2.Init()` в Session 0 драйвер сообщает 0 подключённых устройств (`GetDeviceCount() == 0`) либо завершается с кодом ошибки дескриптора (-1 / -7).
|
||||||
3. Попытка эксклюзивного захвата USB-устройства службой Session 0 блокирует доступ к сканеру со стороны интерактивного пользовательского сеанса для регистрации новых отпечатков.
|
3. Попытка эксклюзивного захвата USB-устройства службой Session 0 блокирует доступ к сканеру со стороны интерактивного пользовательского сеанса для регистрации новых отпечатков.
|
||||||
|
|
||||||
### Принятое решение:
|
### Принятое решение:
|
||||||
|
|
||||||
- **KronaStaffService** работает как системная служба Windows и берет на себя непрерывную синхронизацию очереди отметок, хранение состояния в `%ProgramData%\Krona\StaffTerminal` под защитой Windows DPAPI (`DataProtectionScope.LocalMachine`) и IPC-шлюз.
|
- **KronaStaffService** работает как системная служба Windows и берет на себя непрерывную синхронизацию очереди отметок, хранение состояния в `%ProgramData%\Krona\StaffTerminal` под защитой Windows DPAPI (`DataProtectionScope.LocalMachine`) и IPC-шлюз.
|
||||||
- Интерактивное приложение **KronaStaffTerminal** подключается к сканеру SLK20R в пользовательской сессии (с автозапуском при входе в систему и сворачиванием в трей), выполняет высокоточное распознавание и регистрацию отпечатков, фиксирует отметки и передаёт их в очередь службы.
|
- Интерактивное приложение **KronaStaffTerminal** подключается к сканеру SLK20R в пользовательской сессии (с автозапуском при входе в систему и сворачиванием в трей), выполняет высокоточное распознавание и регистрацию отпечатков, фиксирует отметки и передаёт их в очередь службы.
|
||||||
- Если интерактивный пользователь выходит из системы или закрывает приложение, служба **KronaStaffService** продолжает выгрузку всех накопленных офлайн-событий на сервер.
|
- Если интерактивный пользователь выходит из системы или закрывает приложение, служба **KronaStaffService** продолжает выгрузку всех накопленных офлайн-событий на сервер.
|
||||||
|
|
@ -27,12 +29,14 @@
|
||||||
## Управление службой
|
## Управление службой
|
||||||
|
|
||||||
Установка и регистрация службы:
|
Установка и регистрация службы:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
sc.exe create KronaStaffService binPath= "C:\Program Files\Krona Staff Terminal\KronaStaffService.exe" start= auto obj= "NT AUTHORITY\SYSTEM" DisplayName= "Krona Staff Service"
|
sc.exe create KronaStaffService binPath= "C:\Program Files\Krona Staff Terminal\KronaStaffService.exe" start= auto obj= "NT AUTHORITY\SYSTEM" DisplayName= "Krona Staff Service"
|
||||||
sc.exe start KronaStaffService
|
sc.exe start KronaStaffService
|
||||||
```
|
```
|
||||||
|
|
||||||
Остановка и удаление:
|
Остановка и удаление:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
sc.exe stop KronaStaffService
|
sc.exe stop KronaStaffService
|
||||||
sc.exe delete KronaStaffService
|
sc.exe delete KronaStaffService
|
||||||
|
|
|
||||||
|
|
@ -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:-01J00000000000000000000000}
|
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID}
|
||||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
|
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
|
||||||
ports: ['8080:8080']
|
ports: ['8080:8080']
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
7
deploy/pilot/backup.env.example
Normal file
7
deploy/pilot/backup.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# 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=
|
||||||
4
deploy/pilot/backup.home-server.example
Normal file
4
deploy/pilot/backup.home-server.example
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# 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
|
||||||
53
deploy/pilot/backup.sh
Normal file
53
deploy/pilot/backup.sh
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/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: development
|
NODE_ENV: production
|
||||||
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: ${STANDALONE_MANAGER_TOKEN:?STANDALONE_MANAGER_TOKEN is required}
|
||||||
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN}
|
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN:?STANDALONE_TERMINAL_TOKEN is required}
|
||||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
|
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?STANDALONE_TENANT_ID is required}
|
||||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
|
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:?STANDALONE_ACTOR_ID is required}
|
||||||
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
|
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID:?STANDALONE_TERMINAL_ACTOR_ID is required}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:?VAPID_PUBLIC_KEY is required}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:?VAPID_PRIVATE_KEY is required}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
VAPID_SUBJECT: ${VAPID_SUBJECT:?VAPID_SUBJECT is required}
|
||||||
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
|
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}@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: ${HTTPS_PFX_PASSPHRASE:?HTTPS_PFX_PASSPHRASE is required}
|
||||||
ports:
|
ports:
|
||||||
- '192.168.1.81:8453:8080'
|
- '192.168.1.81:8453:8080'
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -34,7 +34,11 @@ services:
|
||||||
tmpfs: [/tmp]
|
tmpfs: [/tmp]
|
||||||
security_opt: ['no-new-privileges:true']
|
security_opt: ['no-new-privileges:true']
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'wget --no-check-certificate -q -O - https://127.0.0.1:8080/api/v1/staff/live || exit 1']
|
test:
|
||||||
|
[
|
||||||
|
'CMD-SHELL',
|
||||||
|
'wget --no-check-certificate -q -O - https://127.0.0.1:8080/api/v1/staff/live || exit 1',
|
||||||
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -44,8 +48,8 @@ services:
|
||||||
context: ../..
|
context: ../..
|
||||||
target: build
|
target: build
|
||||||
environment:
|
environment:
|
||||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
|
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}@postgres:5432/krona_staff
|
||||||
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
|
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}
|
||||||
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 }
|
||||||
|
|
@ -56,7 +60,7 @@ services:
|
||||||
context: ../..
|
context: ../..
|
||||||
target: build
|
target: build
|
||||||
environment:
|
environment:
|
||||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
|
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}@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 }
|
||||||
|
|
@ -67,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_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
11
deploy/pilot/krona-staff-backup-user.service
Normal file
11
deploy/pilot/krona-staff-backup-user.service
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[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
|
||||||
13
deploy/pilot/krona-staff-backup.service
Normal file
13
deploy/pilot/krona-staff-backup.service
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[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
|
||||||
10
deploy/pilot/krona-staff-backup.timer
Normal file
10
deploy/pilot/krona-staff-backup.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Daily Krona Staff encrypted backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
RandomizedDelaySec=600
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
@ -5,7 +5,20 @@ 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)"
|
||||||
|
|
@ -17,7 +30,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=01J00000000000000000000000\n'
|
printf 'STANDALONE_TENANT_ID=%s\n' "$STANDALONE_TENANT_ID"
|
||||||
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
|
||||||
|
|
@ -25,6 +38,9 @@ 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" \
|
||||||
|
|
@ -34,6 +50,7 @@ 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,3 +7,101 @@ 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,5 +1,7 @@
|
||||||
# Развёртывание
|
# Развёртывание
|
||||||
|
|
||||||
|
После копирования `.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.
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,10 @@ The device driver must be obtained by end-users or enterprise system administrat
|
||||||
The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`apps/KronaStaffTerminal/Vendor/ZKFinger/LICENSE.txt`) contains the following clause in **Section 2 (Description of Other Rights and Limitations)**:
|
The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`apps/KronaStaffTerminal/Vendor/ZKFinger/LICENSE.txt`) contains the following clause in **Section 2 (Description of Other Rights and Limitations)**:
|
||||||
|
|
||||||
> **Distribution.**
|
> **Distribution.**
|
||||||
> *"The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products."*
|
> _"The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products."_
|
||||||
|
|
||||||
### Legal Interpretation:
|
### Legal Interpretation:
|
||||||
|
|
||||||
1. **Commercial Benefit Restriction:** Krona Staff is a commercial workforce management system provided under commercial software licensing terms. Bundling the ZKTeco drivers or redistributable setup packages directly inside the commercial installer (`KronaStaffTerminal-Setup-*.exe`) could be construed as including the software product in a package intended to derive commercial benefit, violating Section 2.
|
1. **Commercial Benefit Restriction:** Krona Staff is a commercial workforce management system provided under commercial software licensing terms. Bundling the ZKTeco drivers or redistributable setup packages directly inside the commercial installer (`KronaStaffTerminal-Setup-*.exe`) could be construed as including the software product in a package intended to derive commercial benefit, violating Section 2.
|
||||||
2. **Permitted Managed Integration:** Building client applications that link against the public SDK wrapper library while requiring users to independently install the vendor-supplied hardware driver for their purchased optical scanner is standard industry practice and fully respects the hardware vendor's EULA.
|
2. **Permitted Managed Integration:** Building client applications that link against the public SDK wrapper library while requiring users to independently install the vendor-supplied hardware driver for their purchased optical scanner is standard industry practice and fully respects the hardware vendor's EULA.
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`
|
||||||
To ensure a seamless operational experience without violating vendor licensing terms, the Krona Staff installer and application implement non-blocking automated driver detection and guidance:
|
To ensure a seamless operational experience without violating vendor licensing terms, the Krona Staff installer and application implement non-blocking automated driver detection and guidance:
|
||||||
|
|
||||||
### 3.1 Installer Detection Flow
|
### 3.1 Installer Detection Flow
|
||||||
|
|
||||||
1. During installation (via `installer/KronaStaffTerminal.nsi`), the installer checks for the presence of the SLK20R / ZKFinger driver across standard system locations:
|
1. During installation (via `installer/KronaStaffTerminal.nsi`), the installer checks for the presence of the SLK20R / ZKFinger driver across standard system locations:
|
||||||
- System driver directory: `%WINDIR%\System32\drivers\slk20r.sys`, `zkfp.sys`, `zkfpos.sys`, `ZKUSB.sys`
|
- System driver directory: `%WINDIR%\System32\drivers\slk20r.sys`, `zkfp.sys`, `zkfpos.sys`, `ZKUSB.sys`
|
||||||
- Windows Service Registry keys:
|
- Windows Service Registry keys:
|
||||||
|
|
@ -47,9 +49,10 @@ To ensure a seamless operational experience without violating vendor licensing t
|
||||||
- The installer does **not** fail or block installation; it completes cleanly so that the terminal and synchronization service are ready once the driver is installed.
|
- The installer does **not** fail or block installation; it completes cleanly so that the terminal and synchronization service are ready once the driver is installed.
|
||||||
|
|
||||||
### 3.2 Terminal Runtime Flow
|
### 3.2 Terminal Runtime Flow
|
||||||
|
|
||||||
1. If the terminal starts without the driver installed:
|
1. If the terminal starts without the driver installed:
|
||||||
- The scanner state transitions to `ScannerState.DriverError` or `ScannerState.Disconnected`.
|
- The scanner state transitions to `ScannerState.DriverError` or `ScannerState.Disconnected`.
|
||||||
- The system tray icon updates to `TrayState.NoScanner` (amber warning icon) with tooltip: *"Сканер отпечатков пальцев не подключен или не установлен драйвер"*.
|
- The system tray icon updates to `TrayState.NoScanner` (amber warning icon) with tooltip: _"Сканер отпечатков пальцев не подключен или не установлен драйвер"_.
|
||||||
- The main UI displays an alert InfoBar with instructions and a link to the official ZKTeco driver download page.
|
- The main UI displays an alert InfoBar with instructions and a link to the official ZKTeco driver download page.
|
||||||
- The terminal remains usable: administrator PIN authentication, employee listing, offline queue synchronization, and manual attendance tracking continue to operate normally.
|
- The terminal remains usable: administrator PIN authentication, employee listing, offline queue synchronization, and manual attendance tracking continue to operate normally.
|
||||||
2. When the administrator plugs in the scanner and installs the driver, the terminal's background auto-reconnect loop automatically detects the device within 2 seconds without requiring an application restart.
|
2. When the administrator plugs in the scanner and installs the driver, the terminal's background auto-reconnect loop automatically detects the device within 2 seconds without requiring an application restart.
|
||||||
|
|
@ -59,6 +62,7 @@ To ensure a seamless operational experience without violating vendor licensing t
|
||||||
## 4. Enterprise Mass Deployment Guide
|
## 4. Enterprise Mass Deployment Guide
|
||||||
|
|
||||||
For automated deployment via Microsoft Intune, Group Policy (GPO), or SCCM:
|
For automated deployment via Microsoft Intune, Group Policy (GPO), or SCCM:
|
||||||
|
|
||||||
1. Download the official ZKTeco SLK20R Driver package (`ZKFinger SDK / Driver Setup`).
|
1. Download the official ZKTeco SLK20R Driver package (`ZKFinger SDK / Driver Setup`).
|
||||||
2. Extract the driver INF and SYS files (`slk20r.inf`, `slk20r.sys`, `slk20rx64.cat`).
|
2. Extract the driver INF and SYS files (`slk20r.inf`, `slk20r.sys`, `slk20rx64.cat`).
|
||||||
3. Deploy the driver package silently using standard Windows DISM or pnputil:
|
3. Deploy the driver package silently using standard Windows DISM or pnputil:
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
This report documents empirical hardware testing and architectural findings gathered during the integration of the ZKTeco SLK20R fingerprint scanner into the Krona Staff Terminal ecosystem.
|
This report documents empirical hardware testing and architectural findings gathered during the integration of the ZKTeco SLK20R fingerprint scanner into the Krona Staff Terminal ecosystem.
|
||||||
|
|
||||||
### Key Takeaways:
|
### Key Takeaways:
|
||||||
|
|
||||||
1. **Session 0 Limitation:** The ZKTeco USB driver and native SDK (`libzkfp.dll`) rely on interactive desktop Windows messaging subsystems (`WM_DEVICECHANGE`) and Win32 user session interfaces. Running biometric capture inside a Windows Service (Session 0) consistently enumerates `0` devices.
|
1. **Session 0 Limitation:** The ZKTeco USB driver and native SDK (`libzkfp.dll`) rely on interactive desktop Windows messaging subsystems (`WM_DEVICECHANGE`) and Win32 user session interfaces. Running biometric capture inside a Windows Service (Session 0) consistently enumerates `0` devices.
|
||||||
2. **Hybrid Solution Adopted:** A decoupled dual-process architecture:
|
2. **Hybrid Solution Adopted:** A decoupled dual-process architecture:
|
||||||
- **`KronaStaffTerminal.exe` (Interactive User Session):** Owns hardware USB scanner lifecycle, capture loop, biometric template matching, and multi-finger enrollment.
|
- **`KronaStaffTerminal.exe` (Interactive User Session):** Owns hardware USB scanner lifecycle, capture loop, biometric template matching, and multi-finger enrollment.
|
||||||
|
|
@ -25,18 +26,23 @@ This report documents empirical hardware testing and architectural findings gath
|
||||||
## 2. Session 0 Investigation & Architectural Analysis
|
## 2. Session 0 Investigation & Architectural Analysis
|
||||||
|
|
||||||
### The Problem
|
### The Problem
|
||||||
|
|
||||||
During development of the headless Windows Service (`KronaStaffService`), tests were performed to verify whether `libzkfp.dll` could capture fingerprints in Session 0 (running as `NT AUTHORITY\SYSTEM`).
|
During development of the headless Windows Service (`KronaStaffService`), tests were performed to verify whether `libzkfp.dll` could capture fingerprints in Session 0 (running as `NT AUTHORITY\SYSTEM`).
|
||||||
|
|
||||||
#### Empirical Observations:
|
#### Empirical Observations:
|
||||||
|
|
||||||
- In Windows Service Session 0, calling `zkfp2.Init()` returns `0` (ZKFP_ERR_OK).
|
- In Windows Service Session 0, calling `zkfp2.Init()` returns `0` (ZKFP_ERR_OK).
|
||||||
- Calling `zkfp2.GetDeviceCount()` immediately following returns `0`, despite the USB scanner being plugged into the machine and operational in Device Manager.
|
- Calling `zkfp2.GetDeviceCount()` immediately following returns `0`, despite the USB scanner being plugged into the machine and operational in Device Manager.
|
||||||
- Attempting to pass handle `0` to `zkfp2.OpenDevice(0)` returns error code `-1` (device not found).
|
- Attempting to pass handle `0` to `zkfp2.OpenDevice(0)` returns error code `-1` (device not found).
|
||||||
|
|
||||||
#### Root Cause:
|
#### Root Cause:
|
||||||
|
|
||||||
The ZKTeco SLK20R driver implementation (`slk20r.sys`) registers device interfaces that bind specifically to interactive Windows desktop sessions. Because Session 0 in modern Windows (Vista through Windows 11/Server 2022) is isolated from window stations, the native driver cannot establish the USB pipe without an interactive user station (`WinSta0`).
|
The ZKTeco SLK20R driver implementation (`slk20r.sys`) registers device interfaces that bind specifically to interactive Windows desktop sessions. Because Session 0 in modern Windows (Vista through Windows 11/Server 2022) is isolated from window stations, the native driver cannot establish the USB pipe without an interactive user station (`WinSta0`).
|
||||||
|
|
||||||
#### Architectural Resolution:
|
#### Architectural Resolution:
|
||||||
|
|
||||||
Instead of forcing unreliable Session 0 USB hooks or interactive service workarounds (which violate Microsoft Windows Security Guidelines), the architecture cleanly divides responsibilities:
|
Instead of forcing unreliable Session 0 USB hooks or interactive service workarounds (which violate Microsoft Windows Security Guidelines), the architecture cleanly divides responsibilities:
|
||||||
|
|
||||||
```
|
```
|
||||||
+--------------------------------------------------------------+
|
+--------------------------------------------------------------+
|
||||||
| Interactive User Session |
|
| Interactive User Session |
|
||||||
|
|
@ -69,14 +75,18 @@ Instead of forcing unreliable Session 0 USB hooks or interactive service workaro
|
||||||
## 3. SDK Device Deduplication Analysis
|
## 3. SDK Device Deduplication Analysis
|
||||||
|
|
||||||
### The Problem
|
### The Problem
|
||||||
|
|
||||||
Under certain driver configurations and USB 3.0/3.1 xHCI host controllers, `zkfp2.GetDeviceCount()` reports `2` or more devices even when only a single physical SLK20R reader is connected.
|
Under certain driver configurations and USB 3.0/3.1 xHCI host controllers, `zkfp2.GetDeviceCount()` reports `2` or more devices even when only a single physical SLK20R reader is connected.
|
||||||
|
|
||||||
Opening device index `1` resulted in one of two failure modes:
|
Opening device index `1` resulted in one of two failure modes:
|
||||||
|
|
||||||
1. `OpenDevice(1)` returned error `-2` (device already open/busy).
|
1. `OpenDevice(1)` returned error `-2` (device already open/busy).
|
||||||
2. `OpenDevice(1)` succeeded as a phantom clone that interfered with image acquisition on device index `0`, causing dropped frames and degraded capture reliability.
|
2. `OpenDevice(1)` succeeded as a phantom clone that interfered with image acquisition on device index `0`, causing dropped frames and degraded capture reliability.
|
||||||
|
|
||||||
### The Solution: Serial Number Deduplication
|
### The Solution: Serial Number Deduplication
|
||||||
|
|
||||||
`FingerprintScanner.cs` inspects hardware parameters for each enumerated slot:
|
`FingerprintScanner.cs` inspects hardware parameters for each enumerated slot:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Query serial number parameter (parameter codes 1101, 1102, 106)
|
// Query serial number parameter (parameter codes 1101, 1102, 106)
|
||||||
byte[] serialBuffer = new byte[256];
|
byte[] serialBuffer = new byte[256];
|
||||||
|
|
@ -87,6 +97,7 @@ if (zkfp2.GetParameters(devHandle, 1101, serialBuffer, ref size) == 0 && size >
|
||||||
serial = Encoding.UTF8.GetString(serialBuffer, 0, size).Trim('\0', ' ', '\r', '\n');
|
serial = Encoding.UTF8.GetString(serialBuffer, 0, size).Trim('\0', ' ', '\r', '\n');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If an enumerated slot returns a serial number that has already been registered by slot `0`, the terminal skips the duplicate slot and logs a diagnostic warning. This guarantees that only one active capture session exists per physical unit.
|
If an enumerated slot returns a serial number that has already been registered by slot `0`, the terminal skips the duplicate slot and logs a diagnostic warning. This guarantees that only one active capture session exists per physical unit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -94,11 +105,13 @@ If an enumerated slot returns a serial number that has already been registered b
|
||||||
## 4. USB Disconnection & Hotplug Recovery Testing
|
## 4. USB Disconnection & Hotplug Recovery Testing
|
||||||
|
|
||||||
### Test Scenario
|
### Test Scenario
|
||||||
|
|
||||||
1. Terminal running in kiosk mode with SLK20R connected.
|
1. Terminal running in kiosk mode with SLK20R connected.
|
||||||
2. Employee clocks in successfully.
|
2. Employee clocks in successfully.
|
||||||
3. USB cable is physically unplugged or hub power is cycled.
|
3. USB cable is physically unplugged or hub power is cycled.
|
||||||
|
|
||||||
### Observed Results
|
### Observed Results
|
||||||
|
|
||||||
- The active capture thread in `zkfp2.AcquireFingerprint` returns negative error code `-101` or `-102` (ZKFP_ERR_IO / disconnect).
|
- The active capture thread in `zkfp2.AcquireFingerprint` returns negative error code `-101` or `-102` (ZKFP_ERR_IO / disconnect).
|
||||||
- Without reconnect logic, earlier versions hung in a tight error loop, pinning the CPU at 100%.
|
- Without reconnect logic, earlier versions hung in a tight error loop, pinning the CPU at 100%.
|
||||||
- **With Production Reconnect Logic:**
|
- **With Production Reconnect Logic:**
|
||||||
|
|
@ -115,12 +128,12 @@ If an enumerated slot returns a serial number that has already been registered b
|
||||||
|
|
||||||
## 5. Performance & Biometric Accuracy Verification
|
## 5. Performance & Biometric Accuracy Verification
|
||||||
|
|
||||||
| Metric | Target | Measured Result | Status |
|
| Metric | Target | Measured Result | Status |
|
||||||
|---|---|---|---|
|
| ------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| Finger placement to verification | < 1000 ms | ~420 ms | PASSED |
|
| Finger placement to verification | < 1000 ms | ~420 ms | PASSED |
|
||||||
| 1:N Biometric Matching (100 templates) | < 100 ms | 14 ms | PASSED |
|
| 1:N Biometric Matching (100 templates) | < 100 ms | 14 ms | PASSED |
|
||||||
| False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED |
|
| False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED |
|
||||||
| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED |
|
| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED |
|
||||||
| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED |
|
| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED |
|
||||||
| Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED |
|
| Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED |
|
||||||
| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING |
|
| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING |
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
## 1. Архитектурный контекст и инварианты
|
## 1. Архитектурный контекст и инварианты
|
||||||
|
|
||||||
Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей:
|
Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей:
|
||||||
|
|
||||||
1. **Единственный источник истины (Single Source of Truth)**: Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины.
|
1. **Единственный источник истины (Single Source of Truth)**: Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины.
|
||||||
2. **User != Employee**: Учётная запись пользователя платформы (`User`) отделена от карточки сотрудника (`Employee`).
|
2. **User != Employee**: Учётная запись пользователя платформы (`User`) отделена от карточки сотрудника (`Employee`).
|
||||||
3. **Строгая изоляция арендаторов (Tenant Isolation)**: Все сущности привязаны к `tenant_id` (ULID). Новые таблицы включают и форсируют PostgreSQL Row Level Security (`FORCE ROW LEVEL SECURITY`), гарантируя невозможность утечки данных между организациями даже на уровне прямого SQL.
|
3. **Строгая изоляция арендаторов (Tenant Isolation)**: Все сущности привязаны к `tenant_id` (ULID). Новые таблицы включают и форсируют PostgreSQL Row Level Security (`FORCE ROW LEVEL SECURITY`), гарантируя невозможность утечки данных между организациями даже на уровне прямого SQL.
|
||||||
|
|
@ -13,9 +14,11 @@ Krona Staff является модулем платформы Krona Business Pl
|
||||||
## 2. Жизненный цикл устройства и аутентификация
|
## 2. Жизненный цикл устройства и аутентификация
|
||||||
|
|
||||||
### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`)
|
### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`)
|
||||||
|
|
||||||
Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`).
|
Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`).
|
||||||
|
|
||||||
**Запрос:**
|
**Запрос:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_code": "TERM-MAIN-01",
|
"device_code": "TERM-MAIN-01",
|
||||||
|
|
@ -26,6 +29,7 @@ Krona Staff является модулем платформы Krona Business Pl
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ответ (201 Created):**
|
**Ответ (201 Created):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device": {
|
"device": {
|
||||||
|
|
@ -48,11 +52,15 @@ Krona Staff является модулем платформы Krona Business Pl
|
||||||
> Сырой токен `token` возвращается клиенту ровно один раз при регистрации. В базе данных (`staff_sync_devices`) сохраняется исключительно криптографический хэш SHA-256 (`token_hash`) и безопасная маска (`token_hint`).
|
> Сырой токен `token` возвращается клиенту ровно один раз при регистрации. В базе данных (`staff_sync_devices`) сохраняется исключительно криптографический хэш SHA-256 (`token_hash`) и безопасная маска (`token_hint`).
|
||||||
|
|
||||||
### 2.2. Аутентификация запросов устройства
|
### 2.2. Аутентификация запросов устройства
|
||||||
|
|
||||||
Каждый запрос от локального терминала к API синхронизации должен содержать заголовок:
|
Каждый запрос от локального терминала к API синхронизации должен содержать заголовок:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
Authorization: Bearer <device_token>
|
Authorization: Bearer <device_token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер:
|
Сервер:
|
||||||
|
|
||||||
1. Вычисляет `SHA-256(device_token)`.
|
1. Вычисляет `SHA-256(device_token)`.
|
||||||
2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`.
|
2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`.
|
||||||
3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`.
|
3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`.
|
||||||
|
|
@ -61,9 +69,11 @@ Authorization: Bearer <device_token>
|
||||||
6. Асинхронно обновляет `last_seen_at`.
|
6. Асинхронно обновляет `last_seen_at`.
|
||||||
|
|
||||||
### 2.3. Отзыв устройства (`POST /api/v1/staff/sync/devices/:deviceId/revoke`)
|
### 2.3. Отзыв устройства (`POST /api/v1/staff/sync/devices/:deviceId/revoke`)
|
||||||
|
|
||||||
При компрометации устройства, утере или списании администратор выполняет отзыв. Статус устройства в БД переводится в `revoked`, фиксируется `revoked_at = now()`.
|
При компрометации устройства, утере или списании администратор выполняет отзыв. Статус устройства в БД переводится в `revoked`, фиксируется `revoked_at = now()`.
|
||||||
|
|
||||||
Все последующие запросы с токеном отозванного устройства немедленно отклоняются со статусом **403 Forbidden** и кодом ошибки `DEVICE_REVOKED`:
|
Все последующие запросы с токеном отозванного устройства немедленно отклоняются со статусом **403 Forbidden** и кодом ошибки `DEVICE_REVOKED`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"statusCode": 403,
|
"statusCode": 403,
|
||||||
|
|
@ -77,28 +87,37 @@ Authorization: Bearer <device_token>
|
||||||
## 3. Монотонный непрозрачный курсор изменений
|
## 3. Монотонный непрозрачный курсор изменений
|
||||||
|
|
||||||
### 3.1. Формат курсора
|
### 3.1. Формат курсора
|
||||||
|
|
||||||
Курсор представляет собой непрозрачную строку вида:
|
Курсор представляет собой непрозрачную строку вида:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
cur_<sequence>_<signature_hash>
|
cur_<sequence>_<signature_hash>
|
||||||
```
|
```
|
||||||
|
|
||||||
Например: `cur_105_7a9f8b2c1d`.
|
Например: `cur_105_7a9f8b2c1d`.
|
||||||
|
|
||||||
- `<sequence>`: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`).
|
- `<sequence>`: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`).
|
||||||
- `<signature_hash>`: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`SHA-256(tenant_id + seq + salt)`).
|
- `<signature_hash>`: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`SHA-256(tenant_id + seq + salt)`).
|
||||||
|
|
||||||
Свойства курсора:
|
Свойства курсора:
|
||||||
|
|
||||||
- **Монотонность**: Гарантирует строго возрастающий порядок чтения журнала изменений.
|
- **Монотонность**: Гарантирует строго возрастающий порядок чтения журнала изменений.
|
||||||
- **Независимость от времени**: Не зависит от системных часов терминала и защищён от сдвигов локального времени.
|
- **Независимость от времени**: Не зависит от системных часов терминала и защищён от сдвигов локального времени.
|
||||||
- **Целостность и изоляция**: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой `INVALID_CURSOR` (400 Bad Request).
|
- **Целостность и изоляция**: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой `INVALID_CURSOR` (400 Bad Request).
|
||||||
|
|
||||||
### 3.2. Начальная загрузка (Bootstrap, `cursor = null`)
|
### 3.2. Начальная загрузка (Bootstrap, `cursor = null`)
|
||||||
|
|
||||||
Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет `POST /api/v1/staff/sync/pull` с пустым или отсутствующим курсором (`cursor: null`):
|
Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет `POST /api/v1/staff/sync/pull` с пустым или отсутствующим курсором (`cursor: null`):
|
||||||
|
|
||||||
1. Сервер формирует снимок текущих **актуальных** данных для поддерживаемых сущностей (`org_unit`, `profession`, `employee`, `work_session`, `timesheet_entry`, `planned_shift`).
|
1. Сервер формирует снимок текущих **актуальных** данных для поддерживаемых сущностей (`org_unit`, `profession`, `employee`, `work_session`, `timesheet_entry`, `planned_shift`).
|
||||||
2. Для каждой сущности формируется безопасная полезная нагрузка с операцией `upsert`.
|
2. Для каждой сущности формируется безопасная полезная нагрузка с операцией `upsert`.
|
||||||
3. В ответе возвращается `next_cursor`, соответствующий текущему максимальному `cursor_seq` журнала изменений сервера.
|
3. В ответе возвращается `next_cursor`, соответствующий текущему максимальному `cursor_seq` журнала изменений сервера.
|
||||||
4. После применения начального снимка терминал фиксирует `next_cursor` и переходит в режим инкрементального получения дельт.
|
4. После применения начального снимка терминал фиксирует `next_cursor` и переходит в режим инкрементального получения дельт.
|
||||||
|
|
||||||
### 3.3. Инкрементальная загрузка (Pull, `POST /api/v1/staff/sync/pull`)
|
### 3.3. Инкрементальная загрузка (Pull, `POST /api/v1/staff/sync/pull`)
|
||||||
|
|
||||||
Запрос:
|
Запрос:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"cursor": "cur_105_7a9f8b2c1d",
|
"cursor": "cur_105_7a9f8b2c1d",
|
||||||
|
|
@ -106,7 +125,9 @@ cur_<sequence>_<signature_hash>
|
||||||
"entity_types": ["employee", "work_session"]
|
"entity_types": ["employee", "work_session"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Ответ:
|
Ответ:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"changes": [
|
"changes": [
|
||||||
|
|
@ -133,12 +154,15 @@ cur_<sequence>_<signature_hash>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`)
|
### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`)
|
||||||
|
|
||||||
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
|
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"cursor": "cur_106_3c4d5e6f7a"
|
"cursor": "cur_106_3c4d5e6f7a"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
|
Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -146,13 +170,16 @@ cur_<sequence>_<signature_hash>
|
||||||
## 4. Пакетная отправка команд (Push) и идемпотентность
|
## 4. Пакетная отправка команд (Push) и идемпотентность
|
||||||
|
|
||||||
### 4.1. Контракт отправки (`POST /api/v1/staff/sync/push`)
|
### 4.1. Контракт отправки (`POST /api/v1/staff/sync/push`)
|
||||||
|
|
||||||
Клиент отправляет накопленные в офлайне команды пачками.
|
Клиент отправляет накопленные в офлайне команды пачками.
|
||||||
|
|
||||||
- Ограничение пакета: от 1 до 100 команд (`BATCH_SIZE_EXCEEDED` при превышении).
|
- Ограничение пакета: от 1 до 100 команд (`BATCH_SIZE_EXCEEDED` при превышении).
|
||||||
- Максимальный размер полезной нагрузки: не более 64 КБ на команду (`PAYLOAD_TOO_LARGE`).
|
- Максимальный размер полезной нагрузки: не более 64 КБ на команду (`PAYLOAD_TOO_LARGE`).
|
||||||
- Максимальный размер HTTP-запроса: 2 МБ.
|
- Максимальный размер HTTP-запроса: 2 МБ.
|
||||||
- Временное окно защиты от Replay: `created_at` команды должно быть не старше 30 дней и не более чем на 5 минут в будущем (`TIMESTAMP_OUT_OF_WINDOW`).
|
- Временное окно защиты от Replay: `created_at` команды должно быть не старше 30 дней и не более чем на 5 минут в будущем (`TIMESTAMP_OUT_OF_WINDOW`).
|
||||||
|
|
||||||
**Пример запроса:**
|
**Пример запроса:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|
@ -174,15 +201,23 @@ cur_<sequence>_<signature_hash>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2. Идемпотентность (`staff_sync_command_inbox`)
|
### 4.2. Идемпотентность (`staff_sync_command_inbox`)
|
||||||
|
|
||||||
Таблица `staff_sync_command_inbox` содержит уникальный ключ:
|
Таблица `staff_sync_command_inbox` содержит уникальный ключ:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
UNIQUE (tenant_id, device_id, idempotency_key)
|
UNIQUE (tenant_id, device_id, idempotency_key)
|
||||||
```
|
```
|
||||||
|
|
||||||
При повторной отправке команды с тем же `idempotency_key`:
|
При повторной отправке команды с тем же `idempotency_key`:
|
||||||
|
|
||||||
1. Мутация повторно не исполняется.
|
1. Мутация повторно не исполняется.
|
||||||
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. Обнаружение конфликтов
|
||||||
|
|
@ -192,6 +227,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
||||||
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
|
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
|
||||||
|
|
||||||
**Ответ с конфликтом (в составе `results`):**
|
**Ответ с конфликтом (в составе `results`):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"idempotency_key": "cmd-term01-778900",
|
"idempotency_key": "cmd-term01-778900",
|
||||||
|
|
@ -210,6 +246,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
||||||
```
|
```
|
||||||
|
|
||||||
Клиент, получив конфликт, обязан:
|
Клиент, получив конфликт, обязан:
|
||||||
|
|
||||||
1. Запросить актуальное состояние сущности через `POST /sync/pull`.
|
1. Запросить актуальное состояние сущности через `POST /sync/pull`.
|
||||||
2. Обновить локальное представление.
|
2. Обновить локальное представление.
|
||||||
3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
|
3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
|
||||||
|
|
@ -220,15 +257,15 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
||||||
|
|
||||||
Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков:
|
Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков:
|
||||||
|
|
||||||
| Сущность | Разрешённые поля | Запрещённые данные |
|
| Сущность | Разрешённые поля | Запрещённые данные |
|
||||||
| :--- | :--- | :--- |
|
| :----------------- | :-------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- |
|
||||||
| `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры |
|
| `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры |
|
||||||
| `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады |
|
| `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады |
|
||||||
| `employee` | `id`, `personnel_number`, `display_name`, `status`, `profession_id`, `position` | **Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия** |
|
| `employee` | `id`, `personnel_number`, `display_name`, `status`, `profession_id`, `position` | **Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия** |
|
||||||
| `work_session` | `id`, `employee_id`, `org_unit_id`, `business_date`, `started_at`, `ended_at`, `worked_minutes`, `status`, `revision` | Стоимость смены, начисления, штрафы |
|
| `work_session` | `id`, `employee_id`, `org_unit_id`, `business_date`, `started_at`, `ended_at`, `worked_minutes`, `status`, `revision` | Стоимость смены, начисления, штрафы |
|
||||||
| `timesheet_entry` | `id`, `employee_id`, `work_date`, `minutes_worked`, `status`, `revision` | Расчётные листы, итоговые выплаты |
|
| `timesheet_entry` | `id`, `employee_id`, `work_date`, `minutes_worked`, `status`, `revision` | Расчётные листы, итоговые выплаты |
|
||||||
| `planned_shift` | `id`, `employee_id`, `org_unit_id`, `business_date`, `planned_minutes`, `start_time`, `end_time` | Плановая стоимость ФОТ смены |
|
| `planned_shift` | `id`, `employee_id`, `org_unit_id`, `business_date`, `planned_minutes`, `start_time`, `end_time` | Плановая стоимость ФОТ смены |
|
||||||
| `attendance_event` | `id`, `employee_id`, `event_type`, `occurred_at`, `source_event_id`, `device_id`, `org_unit_id`, `status` | Сырые биометрические сэмплы сканера |
|
| `attendance_event` | `id`, `employee_id`, `event_type`, `occurred_at`, `source_event_id`, `device_id`, `org_unit_id`, `status` | Сырые биометрические сэмплы сканера |
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!**
|
> **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!**
|
||||||
|
|
@ -247,9 +284,11 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
||||||
## 8. Политика хранения данных (Retention)
|
## 8. Политика хранения данных (Retention)
|
||||||
|
|
||||||
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
|
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT * FROM staff_sync_cleanup_retention(30);
|
SELECT * FROM staff_sync_cleanup_retention(30);
|
||||||
```
|
```
|
||||||
|
|
||||||
- Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30).
|
- Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30).
|
||||||
- Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней.
|
- Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней.
|
||||||
- Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap).
|
- Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap).
|
||||||
|
|
|
||||||
156
docs/RUN-MANUAL-TESTING.md
Normal file
156
docs/RUN-MANUAL-TESTING.md
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
# Руководство по развёртыванию и чек-лист боевого тестирования 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
|
||||||
|
```
|
||||||
59
docs/TENANT-REHOME.md
Normal file
59
docs/TENANT-REHOME.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Перенос арендатора 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 или общую базу.
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Service (`KronaStaffService.exe`) operate on Windows POS / kiosk devices in branch locations. The terminal functions both in fully connected environments and during extended offline network outages.
|
The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Service (`KronaStaffService.exe`) operate on Windows POS / kiosk devices in branch locations. The terminal functions both in fully connected environments and during extended offline network outages.
|
||||||
|
|
||||||
### Security Guarantees
|
### Security Guarantees
|
||||||
|
|
||||||
1. **Biometric Isolation:** Biometric fingerprint templates **NEVER** leave the physical terminal. Templates are stored strictly in local DPAPI-encrypted machine storage (`%ProgramData%\Krona\StaffTerminal\terminal-state.dat`) and are matched on-device using the biometric matching engine. No raw images, templates, minutiae, or biometric features are ever transmitted to the server or written to network logs.
|
1. **Biometric Isolation:** Biometric fingerprint templates **NEVER** leave the physical terminal. Templates are stored strictly in local DPAPI-encrypted machine storage (`%ProgramData%\Krona\StaffTerminal\terminal-state.dat`) and are matched on-device using the biometric matching engine. No raw images, templates, minutiae, or biometric features are ever transmitted to the server or written to network logs.
|
||||||
2. **TLS Mandate:** All communications with the Krona Staff backend **MUST** occur over TLS 1.3 or TLS 1.2 with strict certificate verification. Disabling certificate validation or bypassing SNI/SAN checks is strictly forbidden.
|
2. **TLS Mandate:** All communications with the Krona Staff backend **MUST** occur over TLS 1.3 or TLS 1.2 with strict certificate verification. Disabling certificate validation or bypassing SNI/SAN checks is strictly forbidden.
|
||||||
3. **Transport Protocol:** Synchronous HTTPS POST requests over standard REST endpoints.
|
3. **Transport Protocol:** Synchronous HTTPS POST requests over standard REST endpoints.
|
||||||
|
|
@ -20,18 +21,20 @@ The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Se
|
||||||
## 2. Attendance Sync Endpoint Specification
|
## 2. Attendance Sync Endpoint Specification
|
||||||
|
|
||||||
### Endpoint
|
### Endpoint
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/v1/staff/attendance/events
|
POST /api/v1/staff/attendance/events
|
||||||
```
|
```
|
||||||
|
|
||||||
### Request Headers
|
### Request Headers
|
||||||
| Header Name | Type | Required | Description | Example |
|
|
||||||
|---|---|---|---|---|
|
| Header Name | Type | Required | Description | Example |
|
||||||
| `Content-Type` | String | Yes | Media type of payload | `application/json` |
|
| ----------------- | ------ | -------- | ------------------------------------------------- | -------------------------------------------------------- |
|
||||||
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
|
| `Content-Type` | String | Yes | Media type of payload | `application/json` |
|
||||||
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
|
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
|
||||||
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
|
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
|
||||||
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
|
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
|
||||||
|
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -40,6 +43,7 @@ POST /api/v1/staff/attendance/events
|
||||||
The synchronization client supports sending both individual attendance records and batched events.
|
The synchronization client supports sending both individual attendance records and batched events.
|
||||||
|
|
||||||
### 3.1 Single Event Record Payload
|
### 3.1 Single Event Record Payload
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G",
|
"eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G",
|
||||||
|
|
@ -54,7 +58,9 @@ The synchronization client supports sending both individual attendance records a
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 Batch Sync Payload
|
### 3.2 Batch Sync Payload
|
||||||
|
|
||||||
When draining the offline sync queue after network recovery, the client posts an array of queued events:
|
When draining the offline sync queue after network recovery, the client posts an array of queued events:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"deviceId": "TERM-SPB-OFFICE-01",
|
"deviceId": "TERM-SPB-OFFICE-01",
|
||||||
|
|
@ -83,6 +89,7 @@ When draining the offline sync queue after network recovery, the client posts an
|
||||||
```
|
```
|
||||||
|
|
||||||
### Field Definitions
|
### Field Definitions
|
||||||
|
|
||||||
- **`eventId`** (`string`, ULID or UUIDv4): Client-generated immutable identifier of the attendance event.
|
- **`eventId`** (`string`, ULID or UUIDv4): Client-generated immutable identifier of the attendance event.
|
||||||
- **`employeeId`** (`string`, UUIDv4): Server-recognized unique ID of the employee.
|
- **`employeeId`** (`string`, UUIDv4): Server-recognized unique ID of the employee.
|
||||||
- **`eventType`** (`string`): Enum representing the punch action:
|
- **`eventType`** (`string`): Enum representing the punch action:
|
||||||
|
|
@ -103,15 +110,15 @@ When draining the offline sync queue after network recovery, the client posts an
|
||||||
|
|
||||||
## 4. HTTP Response Codes & Client Handling
|
## 4. HTTP Response Codes & Client Handling
|
||||||
|
|
||||||
| HTTP Status | Meaning | Terminal Client Action |
|
| HTTP Status | Meaning | Terminal Client Action |
|
||||||
|---|---|---|
|
| ------------------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. |
|
| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. |
|
||||||
| `409 Conflict` | Event with `eventId` or `Idempotency-Key` was already committed by server | **Treated as idempotent success.** Dequeue event immediately from local store to prevent head-of-line blocking. |
|
| `409 Conflict` | Event with `eventId` or `Idempotency-Key` was already committed by server | **Treated as idempotent success.** Dequeue event immediately from local store to prevent head-of-line blocking. |
|
||||||
| `400 Bad Request` | Malformed payload schema | Log diagnostic event in crash log; quarantine event into dead-letter store to prevent blocking subsequent events. |
|
| `400 Bad Request` | Malformed payload schema | Log diagnostic event in crash log; quarantine event into dead-letter store to prevent blocking subsequent events. |
|
||||||
| `401 Unauthorized` / `403 Forbidden` | Invalid API token or revoked terminal license | Retain events in offline queue; update system tray status to `TrayState.Error` (`401 Auth Error`); prompt administrator. |
|
| `401 Unauthorized` / `403 Forbidden` | Invalid API token or revoked terminal license | Retain events in offline queue; update system tray status to `TrayState.Error` (`401 Auth Error`); prompt administrator. |
|
||||||
| `429 Too Many Requests` | Server rate limit reached | Back off exponentially, respecting `Retry-After` header if present. |
|
| `429 Too Many Requests` | Server rate limit reached | Back off exponentially, respecting `Retry-After` header if present. |
|
||||||
| `500` / `502` / `503` / `504` | Temporary server-side failure / Gateway error | Retain events in queue; schedule retry with exponential backoff (initial delay 5s, max delay 300s, with randomized jitter). |
|
| `500` / `502` / `503` / `504` | Temporary server-side failure / Gateway error | Retain events in queue; schedule retry with exponential backoff (initial delay 5s, max delay 300s, with randomized jitter). |
|
||||||
| Network Timeout / Socket Error | Terminal offline or disconnected | Keep events in persistent DPAPI-protected queue; retry on timer or upon Windows network change notification (`NetworkChange.NetworkAddressChanged`). |
|
| Network Timeout / Socket Error | Terminal offline or disconnected | Keep events in persistent DPAPI-protected queue; retry on timer or upon Windows network change notification (`NetworkChange.NetworkAddressChanged`). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
126
docs/audit/2026-09-26/ACCEPTANCE-2.md
Normal file
126
docs/audit/2026-09-26/ACCEPTANCE-2.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Приёмка №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 не останется пересечений с синхронизацией.
|
||||||
70
docs/audit/2026-09-26/ACCEPTANCE-3.md
Normal file
70
docs/audit/2026-09-26/ACCEPTANCE-3.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Приёмка №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 добавляются в этот файл после их проверки.
|
||||||
|
|
@ -2,21 +2,25 @@
|
||||||
|
|
||||||
Рабочая папка аудита: исполнители берут отсюда задания и отчитываются через 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 |
|
||||||
| [TASK-ANTIGRAVITY-2.md](TASK-ANTIGRAVITY-2.md) | Antigravity, **текущий круг**: синхронизация, push, оповещения, outbox |
|
| [ACCEPTANCE-2.md](ACCEPTANCE-2.md) | Приёмка второго круга: Grok принят, Codex и Antigravity — что доделать, порядок влития |
|
||||||
| [TASK-GROK-2.md](TASK-GROK-2.md) | Grok, **текущий круг**: запуск стенда под Windows, сопоставление ID, недостающие сценарии, приёмка |
|
| [ACCEPTANCE-3.md](ACCEPTANCE-3.md) | Приёмка третьего круга: Codex принят кроме одного красного теста, порядок влития |
|
||||||
| [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md) | Задания первого круга, для истории |
|
| [TASK-CODEX-2.md](TASK-CODEX-2.md) | Codex, **текущий круг**: форматирование без синхронизации, пилот на настоящего арендатора, сопоставление с платформой |
|
||||||
| [evidence/](evidence) | Сценарии живой проверки и их фактический вывод |
|
| [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) | Сценарии живой проверки и их фактический вывод |
|
||||||
|
|
||||||
## Как работать
|
## Как работать
|
||||||
|
|
||||||
- Каждый исполнитель работает в своих ветках и только в своей зоне файлов — зоны перечислены в задании.
|
- Каждый исполнитель работает в своих ветках и только в своей зоне файлов — зоны перечислены в задании.
|
||||||
- Готовность пункта подтверждается зелёным сценарием стенда Grok (`node scripts/test/run-e2e.mjs`) на живой PostgreSQL, а не тестами на поддельной базе.
|
- Готовность пункта подтверждается зелёным сценарием стенда Grok (`node scripts/test/run-e2e.mjs`) на живой PostgreSQL, а не тестами на поддельной базе.
|
||||||
- Отчёт о выполненном — в описании ветки или коммита: ID, коммит, какой сценарий стал зелёным.
|
- Отчёт о выполненном — в описании ветки или коммита: ID, коммит, какой сценарий стал зелёным.
|
||||||
- Приёмку каждой ветки независимо проводит Grok; итог следующего круга появится здесь как `ACCEPTANCE-2.md`.
|
- Приёмку каждой ветки независимо проводит Grok; итоги — в файлах `ACCEPTANCE-N.md`.
|
||||||
|
|
||||||
Решения владельца (роли, точки, процесс подтверждения, согласие) — раздел 12 в [AUDIT.md](AUDIT.md) и раздел «Решения владельца» в [TASK-CODEX.md](TASK-CODEX.md).
|
Решения владельца (роли, точки, процесс подтверждения, согласие) — раздел 12 в [AUDIT.md](AUDIT.md) и раздел «Решения владельца» в [TASK-CODEX.md](TASK-CODEX.md).
|
||||||
|
|
|
||||||
80
docs/audit/2026-09-26/TASK-ANTIGRAVITY-3.md
Normal file
80
docs/audit/2026-09-26/TASK-ANTIGRAVITY-3.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Задание для 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, что осталось.
|
||||||
80
docs/audit/2026-09-26/TASK-CODEX-2.md
Normal file
80
docs/audit/2026-09-26/TASK-CODEX-2.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Задание для 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 теперь воспроизводятся по существу).
|
||||||
88
docs/audit/2026-09-26/TASK-GROK-3.md
Normal file
88
docs/audit/2026-09-26/TASK-GROK-3.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Задание для 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`;
|
||||||
|
- что не запускалось и почему.
|
||||||
16
migrations/0012_sync_contract_hardening.down.sql
Normal file
16
migrations/0012_sync_contract_hardening.down.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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;
|
||||||
18
migrations/0012_sync_contract_hardening.up.sql
Normal file
18
migrations/0012_sync_contract_hardening.up.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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}$');
|
||||||
12
migrations/0013_owner_workflows.down.sql
Normal file
12
migrations/0013_owner_workflows.down.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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;
|
||||||
125
migrations/0013_owner_workflows.up.sql
Normal file
125
migrations/0013_owner_workflows.up.sql
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
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,9 +1074,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1095,9 +1092,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1116,9 +1110,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1137,9 +1128,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1158,9 +1146,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1179,9 +1164,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1507,9 +1489,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -1691,9 +1673,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv/node_modules/fast-uri": {
|
"node_modules/ajv/node_modules/fast-uri": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -1776,9 +1758,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2265,9 +2247,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "4.1.4",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
|
||||||
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
|
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2662,9 +2644,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2900,9 +2882,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2925,9 +2904,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2950,9 +2926,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2975,9 +2948,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ const currentDefaultPrivileges = await readFile(
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
if (/ALTER DEFAULT PRIVILEGES[\s\S]*?\bGRANT\b/i.test(currentDefaultPrivileges)) {
|
if (/ALTER DEFAULT PRIVILEGES[\s\S]*?\bGRANT\b/i.test(currentDefaultPrivileges)) {
|
||||||
throw new Error('Runtime roles must receive explicit object grants; broad default grants are forbidden');
|
throw new Error(
|
||||||
|
'Runtime roles must receive explicit object grants; broad default grants are forbidden',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(`validated ${ups.length} reversible migration(s)`);
|
console.log(`validated ${ups.length} reversible migration(s)`);
|
||||||
|
|
|
||||||
119
scripts/pull-pilot-backups.sh
Normal file
119
scripts/pull-pilot-backups.sh
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/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"
|
||||||
50
scripts/rehome-tenant.d.mts
Normal file
50
scripts/rehome-tenant.d.mts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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>;
|
||||||
540
scripts/rehome-tenant.mjs
Normal file
540
scripts/rehome-tenant.mjs
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
8
scripts/restore-drill-isolation.sql
Normal file
8
scripts/restore-drill-isolation.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
\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;
|
||||||
27
scripts/restore-drill-report.sql
Normal file
27
scripts/restore-drill-report.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
\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;
|
||||||
23
scripts/run-pilot-backup-pull.ps1
Normal file
23
scripts/run-pilot-backup-pull.ps1
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
$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
|
||||||
|
}
|
||||||
214
scripts/test-backup-pull.sh
Normal file
214
scripts/test-backup-pull.sh
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
#!/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'
|
||||||
137
scripts/test-backup.sh
Normal file
137
scripts/test-backup.sh
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
#!/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'
|
||||||
|
|
@ -52,6 +52,16 @@ try {
|
||||||
console.error('[run-e2e] нет JSON-результатов: стенд не поднялся (см. e2e-run.txt)');
|
console.error('[run-e2e] нет JSON-результатов: стенд не поднялся (см. e2e-run.txt)');
|
||||||
process.exit(1);
|
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
|
const known = strict
|
||||||
? {}
|
? {}
|
||||||
: JSON.parse(readFileSync(path.join(repoRoot, 'tests/e2e/known-defects.json'), 'utf8')).defects;
|
: JSON.parse(readFileSync(path.join(repoRoot, 'tests/e2e/known-defects.json'), 'utf8')).defects;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,10 @@ 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),
|
||||||
|
|
@ -54,7 +57,10 @@ function secureTokenEquals(candidate: string | undefined, expected: string): boo
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
const candidateBuffer = Buffer.from(candidate, 'utf8');
|
const candidateBuffer = Buffer.from(candidate, 'utf8');
|
||||||
const expectedBuffer = Buffer.from(expected, 'utf8');
|
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||||
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
|
return (
|
||||||
|
candidateBuffer.length === expectedBuffer.length &&
|
||||||
|
timingSafeEqual(candidateBuffer, expectedBuffer)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlatformIdentityAdapter implements IdentityPort {
|
export class PlatformIdentityAdapter implements IdentityPort {
|
||||||
|
|
|
||||||
58
src/app.ts
58
src/app.ts
|
|
@ -1,4 +1,4 @@
|
||||||
import Fastify from 'fastify';
|
import Fastify, { type FastifyError } 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,9 +45,30 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
@ -136,14 +157,33 @@ export async function buildApp({
|
||||||
{ prefix: apiPrefix },
|
{ prefix: apiPrefix },
|
||||||
);
|
);
|
||||||
app.get('/', async (_request, reply) => reply.redirect('/staff'));
|
app.get('/', async (_request, reply) => reply.redirect('/staff'));
|
||||||
app.get('/staff', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml));
|
app.get('/staff', async (_request, reply) =>
|
||||||
app.get('/staff/styles.css', async (_request, reply) => reply.type('text/css; charset=utf-8').send(portalCss));
|
reply.type('text/html; charset=utf-8').send(portalHtml),
|
||||||
app.get('/staff/app.js', async (_request, reply) => reply.type('application/javascript; charset=utf-8').send(portalJs));
|
);
|
||||||
app.get('/staff/sw.js', async (_request, reply) => reply.header('service-worker-allowed', '/staff').type('application/javascript; charset=utf-8').send(portalServiceWorker));
|
app.get('/staff/styles.css', async (_request, reply) =>
|
||||||
app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest));
|
reply.type('text/css; charset=utf-8').send(portalCss),
|
||||||
app.get('/staff/icon-192.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon192));
|
);
|
||||||
app.get('/staff/icon-512.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon512));
|
app.get('/staff/app.js', async (_request, reply) =>
|
||||||
app.get('/staff/*', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml));
|
reply.type('application/javascript; charset=utf-8').send(portalJs),
|
||||||
|
);
|
||||||
|
app.get('/staff/sw.js', async (_request, reply) =>
|
||||||
|
reply
|
||||||
|
.header('service-worker-allowed', '/staff')
|
||||||
|
.type('application/javascript; charset=utf-8')
|
||||||
|
.send(portalServiceWorker),
|
||||||
|
);
|
||||||
|
app.get('/staff/manifest.webmanifest', async (_request, reply) =>
|
||||||
|
reply.type('application/manifest+json').send(portalManifest),
|
||||||
|
);
|
||||||
|
app.get('/staff/icon-192.svg', async (_request, reply) =>
|
||||||
|
reply.type('image/svg+xml').send(portalIcon192),
|
||||||
|
);
|
||||||
|
app.get('/staff/icon-512.svg', async (_request, reply) =>
|
||||||
|
reply.type('image/svg+xml').send(portalIcon512),
|
||||||
|
);
|
||||||
|
app.get('/staff/*', async (_request, reply) =>
|
||||||
|
reply.type('text/html; charset=utf-8').send(portalHtml),
|
||||||
|
);
|
||||||
|
|
||||||
app.addHook('onClose', async () => database.close());
|
app.addHook('onClose', async () => database.close());
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
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),
|
||||||
|
|
@ -10,7 +13,10 @@ const configSchema = z.object({
|
||||||
STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
||||||
STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
||||||
STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined(
|
STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined(
|
||||||
z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/).optional(),
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/)
|
||||||
|
.optional(),
|
||||||
),
|
),
|
||||||
HTTPS_PFX_PATH: emptyStringAsUndefined(z.string().optional()),
|
HTTPS_PFX_PATH: emptyStringAsUndefined(z.string().optional()),
|
||||||
HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()),
|
HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()),
|
||||||
|
|
@ -20,7 +26,9 @@ 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.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
STANDALONE_TENANT_ID: z
|
||||||
|
.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()),
|
||||||
|
|
@ -39,6 +47,17 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +68,15 @@ 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') {
|
||||||
throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production');
|
if (
|
||||||
|
!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,4 +6,6 @@ export interface RequestContext {
|
||||||
permissions: ReadonlySet<string>;
|
permissions: ReadonlySet<string>;
|
||||||
correlationId: Ulid;
|
correlationId: Ulid;
|
||||||
causationId?: Ulid;
|
causationId?: Ulid;
|
||||||
|
role?: string;
|
||||||
|
orgUnitIds?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
src/core/outbox.ts
Normal file
45
src/core/outbox.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import { newUlid, type Ulid } from './ids.js';
|
||||||
|
|
||||||
|
export interface OutboxEventInput<TPayload extends Record<string, unknown>> {
|
||||||
|
tenantId: Ulid;
|
||||||
|
eventType: string;
|
||||||
|
subjectType: string;
|
||||||
|
subjectId: Ulid;
|
||||||
|
actorType: 'user' | 'service';
|
||||||
|
actorId: Ulid;
|
||||||
|
payload: TPayload;
|
||||||
|
correlationId: Ulid;
|
||||||
|
causationId?: Ulid;
|
||||||
|
occurredAt?: string;
|
||||||
|
eventVersion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertOutboxEvent<TPayload extends Record<string, unknown>>(
|
||||||
|
client: Pick<PoolClient, 'query'>,
|
||||||
|
event: OutboxEventInput<TPayload>,
|
||||||
|
): Promise<Ulid> {
|
||||||
|
const id = newUlid();
|
||||||
|
const occurredAt = event.occurredAt ?? new Date().toISOString();
|
||||||
|
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, causation_id, occurred_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
event.tenantId,
|
||||||
|
event.eventType,
|
||||||
|
event.eventVersion ?? 1,
|
||||||
|
event.subjectType,
|
||||||
|
event.subjectId,
|
||||||
|
event.actorType,
|
||||||
|
event.actorId,
|
||||||
|
JSON.stringify(event.payload),
|
||||||
|
event.correlationId,
|
||||||
|
event.causationId ?? null,
|
||||||
|
occurredAt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
@ -7,10 +7,7 @@ export interface AttendanceEventInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkSessionAnomaly =
|
export type WorkSessionAnomaly =
|
||||||
| 'auto_closed'
|
'auto_closed' | 'missing_check_in' | 'missing_check_out' | 'repeated_check_in';
|
||||||
| 'missing_check_in'
|
|
||||||
| 'missing_check_out'
|
|
||||||
| 'repeated_check_in';
|
|
||||||
|
|
||||||
export interface ReconcileWorkSessionOptions {
|
export interface ReconcileWorkSessionOptions {
|
||||||
now: string;
|
now: string;
|
||||||
|
|
@ -37,7 +34,10 @@ export function reconcileWorkSession(
|
||||||
events: readonly AttendanceEventInput[],
|
events: readonly AttendanceEventInput[],
|
||||||
options: ReconcileWorkSessionOptions,
|
options: ReconcileWorkSessionOptions,
|
||||||
): WorkSessionCandidate | null {
|
): WorkSessionCandidate | null {
|
||||||
if (!Number.isInteger(options.openShiftThresholdMinutes) || options.openShiftThresholdMinutes < 1) {
|
if (
|
||||||
|
!Number.isInteger(options.openShiftThresholdMinutes) ||
|
||||||
|
options.openShiftThresholdMinutes < 1
|
||||||
|
) {
|
||||||
throw new Error('openShiftThresholdMinutes must be a positive integer');
|
throw new Error('openShiftThresholdMinutes must be a positive integer');
|
||||||
}
|
}
|
||||||
if (events.length === 0) return null;
|
if (events.length === 0) return null;
|
||||||
|
|
@ -46,8 +46,9 @@ export function reconcileWorkSession(
|
||||||
const automaticCloseAt = options.automaticCloseAt
|
const automaticCloseAt = options.automaticCloseAt
|
||||||
? parseInstant(options.automaticCloseAt, 'automaticCloseAt')
|
? parseInstant(options.automaticCloseAt, 'automaticCloseAt')
|
||||||
: undefined;
|
: undefined;
|
||||||
const parsed = events.map(parseEvent).sort((left, right) =>
|
const parsed = events
|
||||||
left.timestamp - right.timestamp || left.id.localeCompare(right.id));
|
.map(parseEvent)
|
||||||
|
.sort((left, right) => left.timestamp - right.timestamp || left.id.localeCompare(right.id));
|
||||||
|
|
||||||
const anomalies = new Set<WorkSessionAnomaly>();
|
const anomalies = new Set<WorkSessionAnomaly>();
|
||||||
const attendanceEventIds: string[] = [];
|
const attendanceEventIds: string[] = [];
|
||||||
|
|
@ -79,9 +80,8 @@ export function reconcileWorkSession(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openAt !== null) {
|
if (openAt !== null) {
|
||||||
const canAutoClose = automaticCloseAt !== undefined
|
const canAutoClose =
|
||||||
&& automaticCloseAt > openAt
|
automaticCloseAt !== undefined && automaticCloseAt > openAt && automaticCloseAt <= now;
|
||||||
&& automaticCloseAt <= now;
|
|
||||||
if (canAutoClose) {
|
if (canAutoClose) {
|
||||||
workedMilliseconds += automaticCloseAt - openAt;
|
workedMilliseconds += automaticCloseAt - openAt;
|
||||||
lastEnd = automaticCloseAt;
|
lastEnd = automaticCloseAt;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ 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;
|
||||||
|
|
@ -77,16 +87,28 @@ 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 Error('Endpoint, p256dh, and auth are required for WebPush subscription');
|
throw new PushValidationError(
|
||||||
|
'Endpoint, p256dh, and auth are required for WebPush subscription',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionId = newUlid();
|
const subscriptionId = newUlid();
|
||||||
|
|
@ -98,16 +120,29 @@ 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,4 +1,5 @@
|
||||||
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)
|
||||||
|
|
@ -149,10 +150,7 @@ export function createVapidAuthorizationHeader(
|
||||||
/**
|
/**
|
||||||
* Encrypt a payload using AES-128-GCM content encoding (RFC 8291 / RFC 8188).
|
* Encrypt a payload using AES-128-GCM content encoding (RFC 8291 / RFC 8188).
|
||||||
*/
|
*/
|
||||||
export function encryptPushPayload(
|
export function encryptPushPayload(payload: string | Buffer, keys: PushSubscriptionKeys): Buffer {
|
||||||
payload: string | Buffer,
|
|
||||||
keys: PushSubscriptionKeys,
|
|
||||||
): Buffer {
|
|
||||||
const plaintext = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
|
const plaintext = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
|
||||||
const recipientP256dh = Buffer.from(keys.p256dh, 'base64url');
|
const recipientP256dh = Buffer.from(keys.p256dh, 'base64url');
|
||||||
const recipientAuth = Buffer.from(keys.auth, 'base64url');
|
const recipientAuth = Buffer.from(keys.auth, 'base64url');
|
||||||
|
|
@ -176,9 +174,7 @@ export function encryptPushPayload(
|
||||||
recipientP256dh,
|
recipientP256dh,
|
||||||
senderPublicKey,
|
senderPublicKey,
|
||||||
]);
|
]);
|
||||||
const prk = Buffer.from(
|
const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32));
|
||||||
crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Generate 16-byte random salt
|
// 3. Generate 16-byte random salt
|
||||||
const salt = crypto.randomBytes(16);
|
const salt = crypto.randomBytes(16);
|
||||||
|
|
@ -217,6 +213,46 @@ export function encryptPushPayload(
|
||||||
/**
|
/**
|
||||||
* 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,
|
||||||
|
|
@ -227,6 +263,22 @@ 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;
|
||||||
|
|
@ -235,13 +287,14 @@ export async function sendWebPush(
|
||||||
const response = await fetchImpl(target.endpoint, {
|
const response = await fetchImpl(target.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': authHeader,
|
Authorization: authHeader,
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'Content-Encoding': 'aes128gcm',
|
'Content-Encoding': 'aes128gcm',
|
||||||
'TTL': String(options?.ttl ?? 86400),
|
TTL: String(options?.ttl ?? 86400),
|
||||||
'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;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { PoolClient } from 'pg';
|
import type { PoolClient } from 'pg';
|
||||||
import type { RequestContext } from '../../core/context.js';
|
import type { RequestContext } from '../../core/context.js';
|
||||||
import { newUlid, parseUlid } from '../../core/ids.js';
|
import { newUlid, parseUlid } from '../../core/ids.js';
|
||||||
|
import { insertOutboxEvent } from '../../core/outbox.js';
|
||||||
import { calculateDay, type DayParticipant, type PayrollLine } from './calculator.js';
|
import { calculateDay, type DayParticipant, type PayrollLine } from './calculator.js';
|
||||||
import { cateringPreset } from './presets.js';
|
import { cateringPreset } from './presets.js';
|
||||||
import type { RuleNode } from './rule.js';
|
import type { RuleNode } from './rule.js';
|
||||||
|
|
@ -14,6 +15,7 @@ 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 {
|
||||||
|
|
@ -293,10 +295,14 @@ 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> = {
|
||||||
rate_minor: data.rate_minor ?? 10000,
|
...(data.params ?? {}),
|
||||||
percent_bp: data.percent_bp ?? 0,
|
...(data.rate_minor !== undefined ? { rate_minor: data.rate_minor } : {}),
|
||||||
guarantee_minor: data.guarantee_minor ?? 0,
|
...(data.percent_bp !== undefined ? { percent_bp: data.percent_bp } : {}),
|
||||||
|
...(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(
|
||||||
|
|
@ -473,6 +479,22 @@ 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],
|
||||||
);
|
);
|
||||||
|
|
@ -767,21 +789,20 @@ export async function calculatePayrollPeriod(
|
||||||
[tenantId, runId, totalAccruedMinor.toString(), calculatedAt],
|
[tenantId, runId, totalAccruedMinor.toString(), calculatedAt],
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await insertOutboxEvent(client, {
|
||||||
`INSERT INTO staff_outbox
|
tenantId,
|
||||||
(id, tenant_id, event_type, event_version, subject_type, subject_id,
|
eventType: 'staff.payroll.run_calculated',
|
||||||
actor_type, actor_id, payload, correlation_id)
|
subjectType: 'PayrollRun',
|
||||||
VALUES ($1, $2, 'staff.payroll.run_calculated', 1, 'PayrollRun', $3,
|
subjectId: parseUlid(runId),
|
||||||
'user', $4, $5, $6)`,
|
actorType: 'user',
|
||||||
[
|
actorId,
|
||||||
newUlid(),
|
payload: {
|
||||||
tenantId,
|
period_start: from,
|
||||||
runId,
|
period_end: to,
|
||||||
actorId,
|
total_accrued_minor: totalAccruedMinor.toString(),
|
||||||
{ period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString() },
|
},
|
||||||
correlationId,
|
correlationId,
|
||||||
],
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_audit_log
|
`INSERT INTO staff_audit_log
|
||||||
|
|
@ -914,25 +935,20 @@ export async function approvePayrollRun(
|
||||||
[tenantId, runId, actorId, approvedAt],
|
[tenantId, runId, actorId, approvedAt],
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await insertOutboxEvent(client, {
|
||||||
`INSERT INTO staff_outbox
|
tenantId,
|
||||||
(id, tenant_id, event_type, event_version, subject_type, subject_id,
|
eventType: 'staff.payroll.run_approved',
|
||||||
actor_type, actor_id, payload, correlation_id)
|
subjectType: 'PayrollRun',
|
||||||
VALUES ($1, $2, 'staff.payroll.run_approved', 1, 'PayrollRun', $3,
|
subjectId: parseUlid(runId),
|
||||||
'user', $4, $5, $6)`,
|
actorType: 'user',
|
||||||
[
|
actorId,
|
||||||
newUlid(),
|
payload: {
|
||||||
tenantId,
|
period_start: run.period_start,
|
||||||
runId,
|
period_end: run.period_end,
|
||||||
actorId,
|
total_accrued_minor: run.total_accrued_minor,
|
||||||
{
|
},
|
||||||
period_start: run.period_start,
|
correlationId,
|
||||||
period_end: run.period_end,
|
});
|
||||||
total_accrued_minor: run.total_accrued_minor,
|
|
||||||
},
|
|
||||||
correlationId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_audit_log
|
`INSERT INTO staff_audit_log
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,9 @@ 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,7 +90,11 @@ 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('Несоответствие арендатора в токене устройства', 'TENANT_MISMATCH', 403);
|
throw new SyncAuthError(
|
||||||
|
'Несоответствие арендатора в токене устройства',
|
||||||
|
'TENANT_MISMATCH',
|
||||||
|
403,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = providedTenantId ?? tokenTenant;
|
const tenantId = providedTenantId ?? tokenTenant;
|
||||||
|
|
@ -187,9 +191,10 @@ 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 = params.allowed_scopes && params.allowed_scopes.length > 0
|
const scopes =
|
||||||
? params.allowed_scopes
|
params.allowed_scopes && params.allowed_scopes.length > 0
|
||||||
: ['attendance:sync'];
|
? params.allowed_scopes
|
||||||
|
: ['attendance:sync'];
|
||||||
|
|
||||||
const orgUnitId = params.org_unit_id ? parseUlid(params.org_unit_id) : null;
|
const orgUnitId = params.org_unit_id ? parseUlid(params.org_unit_id) : null;
|
||||||
|
|
||||||
|
|
@ -210,7 +215,17 @@ 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]!;
|
||||||
|
|
@ -297,6 +312,21 @@ 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)
|
||||||
|
|
@ -319,14 +349,34 @@ export class SyncService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async pull(tenantId: Ulid, deviceId: Ulid, options: PullOptions = {}): Promise<SyncPullResult> {
|
async pull(tenantId: 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 -> incremental pull from staff_sync_change_log
|
// 1. If cursor is provided and NOT a snapshot cursor -> incremental pull from staff_sync_change_log
|
||||||
if (options.cursor) {
|
const isSnapshotCursor =
|
||||||
|
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 = options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
const filterTypes =
|
||||||
|
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;
|
||||||
|
|
@ -360,9 +410,8 @@ export class SyncService {
|
||||||
created_at: r.created_at.toISOString(),
|
created_at: r.created_at.toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const nextCursor = changes.length > 0
|
const nextCursor =
|
||||||
? changes[changes.length - 1]!.cursor
|
changes.length > 0 ? changes[changes.length - 1]!.cursor : options.cursor;
|
||||||
: options.cursor;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changes,
|
changes,
|
||||||
|
|
@ -372,6 +421,12 @@ 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],
|
||||||
|
|
@ -380,14 +435,29 @@ 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.length === 0 || options.entity_types.includes(type);
|
!options.entity_types ||
|
||||||
|
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<{ id: string; code: string; name: string; status: string }>(
|
const orgRes = await client.query<{
|
||||||
`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`,
|
id: string;
|
||||||
|
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) {
|
||||||
|
|
@ -411,7 +481,12 @@ export class SyncService {
|
||||||
|
|
||||||
// Professions
|
// Professions
|
||||||
if (wanted('profession')) {
|
if (wanted('profession')) {
|
||||||
const profRes = await client.query<{ id: string; code: string; name: string; status: string }>(
|
const profRes = await client.query<{
|
||||||
|
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],
|
||||||
);
|
);
|
||||||
|
|
@ -592,12 +667,15 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMore = changes.length > limit;
|
const totalChanges = changes.length;
|
||||||
const pageChanges = changes.slice(0, limit);
|
const pageChanges = changes.slice(snapOffset, snapOffset + limit);
|
||||||
|
const nextOffset = snapOffset + limit;
|
||||||
|
const hasMore = nextOffset < totalChanges;
|
||||||
|
const nextCursor = hasMore ? `snap_${nextOffset}_${maxSeq}` : latestCursor;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changes: pageChanges,
|
changes: pageChanges,
|
||||||
next_cursor: latestCursor,
|
next_cursor: nextCursor,
|
||||||
has_more: hasMore,
|
has_more: hasMore,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -662,7 +740,8 @@ 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: 'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
|
error_message:
|
||||||
|
'Временная метка команды находится вне допустимого окна (-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);
|
||||||
|
|
@ -698,7 +777,25 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Execute command
|
// 4. Execute command
|
||||||
const executionResult = await this.executeCommand(client, tenantId, device, cmd);
|
await client.query('SAVEPOINT 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);
|
||||||
}
|
}
|
||||||
|
|
@ -722,7 +819,6 @@ 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 {
|
||||||
|
|
@ -734,9 +830,35 @@ export class SyncService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify employee belongs to tenant
|
// Validate timestamp window: -180 days .. +5 min
|
||||||
|
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`,
|
`SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2 AND status = 'active'`,
|
||||||
[tenantId, employeeId],
|
[tenantId, employeeId],
|
||||||
);
|
);
|
||||||
if (empCheck.rowCount === 0) {
|
if (empCheck.rowCount === 0) {
|
||||||
|
|
@ -744,20 +866,45 @@ 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_NOT_FOUND',
|
error_code: 'EMPLOYEE_INACTIVE',
|
||||||
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)
|
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id, org_unit_id)
|
||||||
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10)
|
||||||
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 = {
|
||||||
|
|
@ -767,11 +914,19 @@ 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: orgUnitId ? String(orgUnitId) : null,
|
org_unit_id: effectiveOrgUnitId,
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.recordChange(client, tenantId, 'attendance_event', eventId, 'upsert', 1, safePayload);
|
await this.recordChange(
|
||||||
|
client,
|
||||||
|
tenantId,
|
||||||
|
'attendance_event',
|
||||||
|
eventId,
|
||||||
|
'upsert',
|
||||||
|
1,
|
||||||
|
safePayload,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
idempotency_key: cmd.idempotency_key,
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
|
@ -781,207 +936,15 @@ export class SyncService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'upsert_work_session': {
|
case 'upsert_work_session':
|
||||||
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 {
|
|
||||||
idempotency_key: cmd.idempotency_key,
|
|
||||||
command_type: cmd.command_type,
|
|
||||||
status: 'rejected',
|
|
||||||
error_code: 'INVALID_WORK_SESSION_PAYLOAD',
|
|
||||||
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': {
|
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 {
|
return {
|
||||||
idempotency_key: cmd.idempotency_key,
|
idempotency_key: cmd.idempotency_key,
|
||||||
command_type: cmd.command_type,
|
command_type: cmd.command_type,
|
||||||
status: 'applied',
|
status: 'rejected',
|
||||||
result: { id: entryId, revision: newRev, status: 'applied' },
|
error_code: 'COMMAND_NOT_SUPPORTED',
|
||||||
|
error_message:
|
||||||
|
'Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1052,17 +1015,23 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cleanupRetention(retentionDays = 30): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
async cleanupRetention(
|
||||||
return this.database.tenantTransaction(this.systemActorId, this.systemActorId, async (client) => {
|
retentionDays = 30,
|
||||||
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
||||||
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
return this.database.tenantTransaction(
|
||||||
[retentionDays],
|
this.systemActorId,
|
||||||
);
|
this.systemActorId,
|
||||||
const row = res.rows[0];
|
async (client) => {
|
||||||
return {
|
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
||||||
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
||||||
purged_commands: Number(row?.purged_commands ?? 0),
|
[retentionDays],
|
||||||
};
|
);
|
||||||
});
|
const row = res.rows[0];
|
||||||
|
return {
|
||||||
|
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
||||||
|
purged_commands: Number(row?.purged_commands ?? 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,12 @@ 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
|
||||||
|
|
@ -569,18 +575,23 @@ 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 },
|
rate_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
||||||
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
||||||
guarantee_minor: { type: 'integer', minimum: 0 },
|
guarantee_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
||||||
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 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -660,9 +671,23 @@ 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]!;
|
||||||
|
|
@ -773,6 +798,53 @@ 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',
|
||||||
{
|
{
|
||||||
|
|
@ -1018,18 +1090,20 @@ 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 AND business_date = $3
|
WHERE tenant_id = $1 AND employee_id = $2
|
||||||
AND (external_key LIKE $4 OR external_key LIKE $5)`,
|
AND (external_key = $3 OR external_key LIKE $3 || ':%')`,
|
||||||
[context.tenantId, employeeId, entry.business_date, legacyPrefix, currentPrefix],
|
[context.tenantId, employeeId, entry.external_key],
|
||||||
);
|
);
|
||||||
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,
|
||||||
|
|
@ -1041,12 +1115,12 @@ export async function registerOperationsRoutes(
|
||||||
context.tenantId,
|
context.tenantId,
|
||||||
employeeId,
|
employeeId,
|
||||||
entry.business_date,
|
entry.business_date,
|
||||||
previousAmount === 0 ? entry.kind : 'adjustment',
|
isInitial ? 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,
|
||||||
entry.external_key,
|
rowKey,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1202,7 +1276,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1035
src/portal.ts
1035
src/portal.ts
File diff suppressed because it is too large
Load diff
31
src/session.ts
Normal file
31
src/session.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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,13 +4,10 @@ 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 {
|
import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
|
||||||
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;
|
||||||
|
|
@ -71,7 +68,25 @@ 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';
|
||||||
|
|
@ -82,7 +97,8 @@ 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 {
|
||||||
token = decodeURIComponent(part.slice(sep + 1).trim());
|
const rawSessionId = decodeURIComponent(part.slice(sep + 1).trim());
|
||||||
|
token = getSessionToken(rawSessionId);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed cookie
|
// ignore malformed cookie
|
||||||
}
|
}
|
||||||
|
|
@ -91,20 +107,30 @@ 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(
|
||||||
|
'Требуется действующий ключ доступа руководителя',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -169,7 +195,11 @@ 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(manager.tenantId, manager.actorId, request.body);
|
const result = await syncService.registerDevice(
|
||||||
|
manager.tenantId,
|
||||||
|
manager.actorId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
return reply.code(201).send(result);
|
return reply.code(201).send(result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -257,7 +287,8 @@ export async function registerSyncRoutes(
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Sync'],
|
tags: ['Sync'],
|
||||||
summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
summary:
|
||||||
|
'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
||||||
body: {
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -279,7 +310,15 @@ export async function registerSyncRoutes(
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['cursor', 'entity_type', 'entity_id', 'operation', 'revision', 'payload', 'created_at'],
|
required: [
|
||||||
|
'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' },
|
||||||
|
|
@ -330,7 +369,14 @@ export async function registerSyncRoutes(
|
||||||
maxItems: 100,
|
maxItems: 100,
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['command_type', 'schema_version', 'idempotency_key', 'correlation_id', 'created_at', 'payload'],
|
required: [
|
||||||
|
'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 },
|
||||||
|
|
@ -387,7 +433,11 @@ 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(device.tenant_id, device, request.body?.commands ?? []);
|
const results = await syncService.push(
|
||||||
|
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);
|
||||||
|
|
@ -428,7 +478,11 @@ 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(device.tenant_id, device.id, request.body.cursor);
|
const result = await syncService.acknowledge(
|
||||||
|
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);
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,7 @@ async function seed(tenant: string) {
|
||||||
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
|
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const seedSessionIds: string[] = [];
|
||||||
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
||||||
await q(
|
await q(
|
||||||
'staff_attendance_events',
|
'staff_attendance_events',
|
||||||
|
|
@ -231,11 +232,14 @@ async function seed(tenant: string) {
|
||||||
corr(),
|
corr(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const sid = randomUlid();
|
||||||
|
seedSessionIds.push(sid);
|
||||||
await q(
|
await q(
|
||||||
'staff_work_sessions',
|
'staff_work_sessions',
|
||||||
'INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1,$2,$3,$4,$5)',
|
'INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1,$2,$3,$4,$5)',
|
||||||
[randomUlid(), tenant, emp, ou1, '2026-09-01'],
|
[sid, tenant, emp, ou1, '2026-09-01'],
|
||||||
);
|
);
|
||||||
|
|
||||||
await q(
|
await q(
|
||||||
'staff_timesheet_entries',
|
'staff_timesheet_entries',
|
||||||
'INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
'INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
||||||
|
|
@ -377,7 +381,44 @@ async function seed(tenant: string) {
|
||||||
[device, tenant],
|
[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,
|
actor,
|
||||||
);
|
);
|
||||||
return { employees, seeded };
|
return { employees, seeded };
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,4 @@
|
||||||
{
|
{
|
||||||
"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.",
|
"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": {
|
"defects": {}
|
||||||
"K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
|
|
||||||
"K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
|
|
||||||
"K12/N3": "SSRF через push-endpoint на внутренние адреса",
|
|
||||||
"K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
|
|
||||||
"N9": "перепривязка чужого push-endpoint",
|
|
||||||
"N10": "справочники доступны терминалу",
|
|
||||||
"N11": "импорт с неверным kind → 500",
|
|
||||||
"N12": "pay-assignment без additionalProperties:false и верхних границ",
|
|
||||||
"K1/F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму",
|
|
||||||
"K10/F3": "cookie = сырой токен, logout не отзывает",
|
|
||||||
"K11/F4": "нет rate limit на вход",
|
|
||||||
"F7": "Idempotency-Key не используется",
|
|
||||||
"F10": "CSRF: мутации по cookie без Origin разрешены",
|
|
||||||
"K13/SYNC-A": "устройство само подтверждает смену через upsert_work_session",
|
|
||||||
"K13/SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry",
|
|
||||||
"SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)",
|
|
||||||
"S5/SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)",
|
|
||||||
"S5/SYNC-D": "изменения из портала не пишутся в staff_sync_change_log",
|
|
||||||
"S4/SYNC-E": "отметки уволенных сотрудников принимаются через sync",
|
|
||||||
"S4/SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку",
|
|
||||||
"S3/SYNC-G": "одна битая команда роняет весь пакет (500)",
|
|
||||||
"S8/SYNC-H": "регистрация устройства по cookie без проверки Origin",
|
|
||||||
"K1/SYNC-I": "без токенов анонимно регистрируется устройство",
|
|
||||||
"S2/SYNC-J": "sync принимает отметки вне окна времени",
|
|
||||||
"SYNC-K": "ack принимает курсор больше выданного сервером",
|
|
||||||
"K1/PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)",
|
|
||||||
"K2/PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development",
|
|
||||||
"D6/OWN-01": "решение владельца: роли точки (этап 4 Codex)",
|
|
||||||
"D6/OWN-02": "решение владельца: пакет отправляет только бухгалтер",
|
|
||||||
"OWN-03": "решение владельца: уведомления директорам",
|
|
||||||
"OWN-04": "решение владельца: подписывает только директор",
|
|
||||||
"OWN-05": "решение владельца: ledger пуст до подписи",
|
|
||||||
"OWN-06": "решение владельца: подпись → ledger, неизменяемость",
|
|
||||||
"K19/OWN-07": "решение владельца: изменение смены после отправки возвращает пакет",
|
|
||||||
"OWN-08": "решение владельца: возврат с комментарием",
|
|
||||||
"OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки",
|
|
||||||
"K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
|
|
||||||
"K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала",
|
|
||||||
"K6/D5": "отклонение смены из подписанной ведомости: 200 без сторно (сейчас блокирован K3 на подтверждении)",
|
|
||||||
"K7/D2": "повторный импорт книги теряет суммы: две точки в один день → 222,00 вместо 333,00 ₽",
|
|
||||||
"K8/D1": "подписанная ведомость не воспроизводится после смены ставки; контракта verify нет (C4), сейчас блокирован K3",
|
|
||||||
"K9/D3": "параметры пресета официанта (shift_rate_minor и др.) не сохраняются",
|
|
||||||
"S9": "удаление точки с устройством: 23502 null value in column tenant_id (ON DELETE SET NULL на составном FK)",
|
|
||||||
"A4": "ответы 500 содержат SQLSTATE и текст PostgreSQL",
|
|
||||||
"N12-DATE": "month=2026-13 → 500 (22008) вместо 400",
|
|
||||||
"PILOT-REHOME": "нет scripts/rehome-tenant.mjs (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 на настоящий tenant не реализован"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
|
||||||
126
tests/integration/live-postgres-remediation.test.ts
Normal file
126
tests/integration/live-postgres-remediation.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildApp } from '../../src/app.js';
|
||||||
|
import { loadConfig } from '../../src/config.js';
|
||||||
|
import { newUlid } from '../../src/core/ids.js';
|
||||||
|
import { insertOutboxEvent } from '../../src/core/outbox.js';
|
||||||
|
import { Database } from '../../src/infrastructure/database.js';
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_TEST_URL;
|
||||||
|
const isolatedDatabase = process.env.DATABASE_TEST_ISOLATED === 'true';
|
||||||
|
const guardToken = process.env.DATABASE_TEST_GUARD_TOKEN;
|
||||||
|
const describeDatabase =
|
||||||
|
connectionString && isolatedDatabase && guardToken ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeDatabase('live PostgreSQL audit remediations', () => {
|
||||||
|
it('creates an employee and completes the outbox-producing shift and payroll workflow', async () => {
|
||||||
|
const database = new Database(connectionString!);
|
||||||
|
const tenantId = newUlid();
|
||||||
|
const actorId = newUlid();
|
||||||
|
const managerToken = 'm'.repeat(64);
|
||||||
|
const orgUnitId = newUlid();
|
||||||
|
const sessionId = newUlid();
|
||||||
|
const currentDatabase = await database.pool.query<{ name: string }>(
|
||||||
|
'SELECT current_database() AS name',
|
||||||
|
);
|
||||||
|
expect(currentDatabase.rows[0]?.name).toMatch(/^krona_staff_audit_[a-z0-9_]+$/);
|
||||||
|
const role = await database.pool.query<{ rolsuper: boolean; rolbypassrls: boolean }>(
|
||||||
|
'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user',
|
||||||
|
);
|
||||||
|
expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
|
||||||
|
const guard = await database.pool.query<{ marker: string }>(
|
||||||
|
'SELECT marker FROM krona_staff_test_guard WHERE marker = $1',
|
||||||
|
[guardToken],
|
||||||
|
);
|
||||||
|
expect(guard.rows).toEqual([{ marker: guardToken }]);
|
||||||
|
const probeOutboxId = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||||
|
insertOutboxEvent(client, {
|
||||||
|
tenantId,
|
||||||
|
eventType: 'staff.audit_probe.created',
|
||||||
|
subjectType: 'AuditProbe',
|
||||||
|
subjectId: newUlid(),
|
||||||
|
actorType: 'user',
|
||||||
|
actorId,
|
||||||
|
payload: { source: 'live-postgres-test' },
|
||||||
|
correlationId: newUlid(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const probe = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||||
|
client.query<{ occurred_at: Date }>('SELECT occurred_at FROM staff_outbox WHERE id = $1', [
|
||||||
|
probeOutboxId,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(probe.rows[0]?.occurred_at).toBeTruthy();
|
||||||
|
|
||||||
|
const app = await buildApp({
|
||||||
|
config: loadConfig({
|
||||||
|
DATABASE_URL: connectionString!,
|
||||||
|
STANDALONE_TENANT_ID: tenantId,
|
||||||
|
STANDALONE_ACTOR_ID: actorId,
|
||||||
|
STANDALONE_MANAGER_TOKEN: managerToken,
|
||||||
|
STANDALONE_TERMINAL_TOKEN: 't'.repeat(64),
|
||||||
|
STANDALONE_ACCESS: 'manager',
|
||||||
|
LOG_LEVEL: 'silent',
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
}),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
const authorization = { authorization: `Bearer ${managerToken}` };
|
||||||
|
try {
|
||||||
|
const employee = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/staff/employees',
|
||||||
|
headers: authorization,
|
||||||
|
payload: {
|
||||||
|
personnel_number: `AUDIT-${newUlid()}`,
|
||||||
|
display_name: 'Тестовый сотрудник',
|
||||||
|
position: 'Тестовая профессия',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(employee.statusCode, employee.body).toBe(201);
|
||||||
|
const employeeId = employee.json<{ id: string }>().id;
|
||||||
|
|
||||||
|
await database.tenantTransaction(tenantId, actorId, async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_org_units (id, tenant_id, code, name)
|
||||||
|
VALUES ($1, $2, $3, 'Тестовая точка')`,
|
||||||
|
[orgUnitId, tenantId, `audit-${newUlid().toLowerCase()}`],
|
||||||
|
);
|
||||||
|
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)
|
||||||
|
VALUES ($1, $2, $3, $4, '2040-01-15', '2040-01-15T01:00:00Z',
|
||||||
|
'2040-01-15T09:00:00Z', 480, 'pending_review')`,
|
||||||
|
[sessionId, tenantId, employeeId, orgUnitId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmed = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/staff/work-sessions/${sessionId}`,
|
||||||
|
headers: authorization,
|
||||||
|
payload: { status: 'confirmed' },
|
||||||
|
});
|
||||||
|
expect(confirmed.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const calculated = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/staff/payroll/calculate',
|
||||||
|
headers: authorization,
|
||||||
|
payload: { month: '2040-01' },
|
||||||
|
});
|
||||||
|
expect(calculated.statusCode).toBe(200);
|
||||||
|
const runId = calculated.json<{ run_id: string }>().run_id;
|
||||||
|
|
||||||
|
const approved = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/staff/payroll/runs/${runId}/approve`,
|
||||||
|
headers: authorization,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(approved.statusCode).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
@ -54,6 +54,7 @@ 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: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
@ -98,6 +98,33 @@ 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({
|
||||||
|
|
@ -213,9 +240,10 @@ describe('attendance portal', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(201);
|
expect(response.statusCode).toBe(201);
|
||||||
expect(calls).toHaveLength(2);
|
expect(calls).toHaveLength(3);
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -246,7 +274,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,
|
||||||
|
|
@ -258,10 +286,11 @@ describe('attendance portal', () => {
|
||||||
payload: { position: 'Старший администратор' },
|
payload: { position: 'Старший администратор' },
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(calls).toHaveLength(4);
|
expect(calls).toHaveLength(5);
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
548
tests/integration/rehome-tenant.test.ts
Normal file
548
tests/integration/rehome-tenant.test.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
172
tests/integration/sync-bootstrap.test.ts
Normal file
172
tests/integration/sync-bootstrap.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
208
tests/integration/sync-rejection.test.ts
Normal file
208
tests/integration/sync-rejection.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
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,7 +1,7 @@
|
||||||
import pg from 'pg';
|
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, parseUlid } from '../../src/core/ids.js';
|
import { newUlid } 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;
|
||||||
|
|
@ -12,6 +12,11 @@ const describeDatabase = connectionString ? describe : describe.skip;
|
||||||
const migrationConnectionString =
|
const migrationConnectionString =
|
||||||
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
||||||
const cleanupTables = [
|
const cleanupTables = [
|
||||||
|
'staff_biometric_consents',
|
||||||
|
'staff_batch_notifications',
|
||||||
|
'staff_payroll_batch_lines',
|
||||||
|
'staff_payroll_batches',
|
||||||
|
'staff_access_grants',
|
||||||
'staff_sync_device_checkpoints',
|
'staff_sync_device_checkpoints',
|
||||||
'staff_sync_command_inbox',
|
'staff_sync_command_inbox',
|
||||||
'staff_sync_change_log',
|
'staff_sync_change_log',
|
||||||
|
|
@ -25,10 +30,10 @@ const cleanupTables = [
|
||||||
|
|
||||||
describeDatabase('PostgreSQL tenant isolation', () => {
|
describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
const database = new Database(connectionString!);
|
const database = new Database(connectionString!);
|
||||||
const tenantA = parseUlid('01J0000000000000000000000A');
|
const tenantA = newUlid();
|
||||||
const tenantB = parseUlid('01J0000000000000000000000B');
|
const tenantB = newUlid();
|
||||||
const actorA = parseUlid('01J0000000000000000000001A');
|
const actorA = newUlid();
|
||||||
const actorB = parseUlid('01J0000000000000000000001B');
|
const actorB = newUlid();
|
||||||
const eventId = newUlid();
|
const eventId = newUlid();
|
||||||
const professionId = newUlid();
|
const professionId = newUlid();
|
||||||
const employeeIdA = newUlid();
|
const employeeIdA = newUlid();
|
||||||
|
|
@ -311,5 +316,56 @@ 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: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -18,6 +18,33 @@ 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({
|
||||||
|
|
@ -29,12 +56,12 @@ describe('runtime modes', () => {
|
||||||
}).PLATFORM_GATEWAY_URL,
|
}).PLATFORM_GATEWAY_URL,
|
||||||
).toBeUndefined());
|
).toBeUndefined());
|
||||||
it('requires VAPID keys to be configured together if provided', () => {
|
it('requires VAPID keys to be configured together if provided', () => {
|
||||||
expect(() =>
|
expect(() => loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' })).toThrow(
|
||||||
loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' }),
|
/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/,
|
||||||
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
|
);
|
||||||
expect(() =>
|
expect(() => loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' })).toThrow(
|
||||||
loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' }),
|
/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/,
|
||||||
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
|
);
|
||||||
const config = loadConfig({
|
const config = loadConfig({
|
||||||
...base,
|
...base,
|
||||||
VAPID_PUBLIC_KEY: 'pubkey',
|
VAPID_PUBLIC_KEY: 'pubkey',
|
||||||
|
|
@ -46,4 +73,3 @@ describe('runtime modes', () => {
|
||||||
expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local');
|
expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,7 @@ function decryptPushPayload(
|
||||||
recipient.publicKey,
|
recipient.publicKey,
|
||||||
senderPublicKey,
|
senderPublicKey,
|
||||||
]);
|
]);
|
||||||
const prk = Buffer.from(
|
const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32));
|
||||||
crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
|
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
|
||||||
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16));
|
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16));
|
||||||
|
|
@ -79,12 +77,12 @@ describe('WebPush VAPID key management', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects mismatched VAPID configuration when only one key is set', () => {
|
it('rejects mismatched VAPID configuration when only one key is set', () => {
|
||||||
expect(() =>
|
expect(() => resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' })).toThrow(
|
||||||
resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' }),
|
/must be configured together/,
|
||||||
).toThrow(/must be configured together/);
|
);
|
||||||
expect(() =>
|
expect(() => resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' })).toThrow(
|
||||||
resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' }),
|
/must be configured together/,
|
||||||
).toThrow(/must be configured together/);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -172,9 +170,9 @@ describe('WebPush RFC 8291 / RFC 8188 AES-128-GCM encryption', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid recipient keys with clear error', () => {
|
it('rejects invalid recipient keys with clear error', () => {
|
||||||
expect(() =>
|
expect(() => encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' })).toThrow(
|
||||||
encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' }),
|
/Invalid recipient/,
|
||||||
).toThrow(/Invalid recipient/);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -377,7 +375,11 @@ describe('PushDispatcher idempotent outbox handling', () => {
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Push delivery is tracked independently from platform event publication.
|
// Push delivery is tracked independently from platform event publication.
|
||||||
expect(queries.some((q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'))).toBe(true);
|
expect(
|
||||||
|
queries.some(
|
||||||
|
(q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false);
|
expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
56
tests/unit/outbox.test.ts
Normal file
56
tests/unit/outbox.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { insertOutboxEvent } from '../../src/core/outbox.js';
|
||||||
|
import { parseUlid } from '../../src/core/ids.js';
|
||||||
|
|
||||||
|
describe('transactional outbox helper', () => {
|
||||||
|
it('writes a complete platform event envelope with an occurrence time', async () => {
|
||||||
|
const query = vi.fn(async (...args: [string, readonly unknown[] | undefined]) => {
|
||||||
|
void args;
|
||||||
|
return { rows: [], rowCount: 1 };
|
||||||
|
});
|
||||||
|
const tenantId = parseUlid('01J0000000000000000000000A');
|
||||||
|
const actorId = parseUlid('01J0000000000000000000001A');
|
||||||
|
const subjectId = parseUlid('01J0000000000000000000002A');
|
||||||
|
const correlationId = parseUlid('01J0000000000000000000003A');
|
||||||
|
|
||||||
|
await insertOutboxEvent({ query } as never, {
|
||||||
|
tenantId,
|
||||||
|
eventType: 'staff.test.created',
|
||||||
|
subjectType: 'TestSubject',
|
||||||
|
subjectId,
|
||||||
|
actorType: 'user',
|
||||||
|
actorId,
|
||||||
|
payload: { state: 'created' },
|
||||||
|
correlationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query).toHaveBeenCalledOnce();
|
||||||
|
const [sql, parameters] = query.mock.calls[0] as unknown as [string, unknown[]];
|
||||||
|
expect(sql).toContain('causation_id, occurred_at');
|
||||||
|
expect(parameters[8]).toBe('{"state":"created"}');
|
||||||
|
expect(parameters[11]).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a source occurrence time for imported or terminal events', async () => {
|
||||||
|
const query = vi.fn(async (...args: [string, readonly unknown[] | undefined]) => {
|
||||||
|
void args;
|
||||||
|
return { rows: [], rowCount: 1 };
|
||||||
|
});
|
||||||
|
const occurredAt = '2026-09-24T01:00:00.000Z';
|
||||||
|
|
||||||
|
await insertOutboxEvent({ query } as never, {
|
||||||
|
tenantId: parseUlid('01J0000000000000000000000A'),
|
||||||
|
eventType: 'attendance.clock.in',
|
||||||
|
subjectType: 'Employee',
|
||||||
|
subjectId: parseUlid('01J0000000000000000000002A'),
|
||||||
|
actorType: 'service',
|
||||||
|
actorId: parseUlid('01J0000000000000000000001A'),
|
||||||
|
payload: { source: 'terminal' },
|
||||||
|
correlationId: parseUlid('01J0000000000000000000003A'),
|
||||||
|
occurredAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parameters = query.mock.calls[0]?.[1] as readonly unknown[];
|
||||||
|
expect(parameters[11]).toBe(occurredAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { portalCssEnhanced, portalHtml, portalJsEnhanced, portalManifest } from '../../src/portal-ui.js';
|
import {
|
||||||
|
portalCssEnhanced,
|
||||||
|
portalHtml,
|
||||||
|
portalJsEnhanced,
|
||||||
|
portalManifest,
|
||||||
|
} from '../../src/portal-ui.js';
|
||||||
|
|
||||||
describe('Krona Staff workspace shell', () => {
|
describe('Krona Staff workspace shell', () => {
|
||||||
it('exposes the operational workspace sections with deep routes', () => {
|
it('exposes the operational workspace sections with deep routes', () => {
|
||||||
|
|
@ -33,6 +38,9 @@ describe('Krona Staff workspace shell', () => {
|
||||||
expect(portalJsEnhanced).toContain("'/staff/attendance':'attendance-view'");
|
expect(portalJsEnhanced).toContain("'/staff/attendance':'attendance-view'");
|
||||||
expect(portalJsEnhanced).toContain("'/staff/reports':'reports-view'");
|
expect(portalJsEnhanced).toContain("'/staff/reports':'reports-view'");
|
||||||
expect(portalJsEnhanced).toContain("window.addEventListener('popstate'");
|
expect(portalJsEnhanced).toContain("window.addEventListener('popstate'");
|
||||||
expect(JSON.parse(portalManifest)).toMatchObject({ start_url: '/staff/dashboard', scope: '/staff' });
|
expect(JSON.parse(portalManifest)).toMatchObject({
|
||||||
|
start_url: '/staff/dashboard',
|
||||||
|
scope: '/staff',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
66
tests/unit/rehome-tenant.test.ts
Normal file
66
tests/unit/rehome-tenant.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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,7 +8,9 @@ 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(queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }) {
|
function createMockDb(
|
||||||
|
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 });
|
||||||
|
|
@ -105,7 +107,9 @@ 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((q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at')),
|
queries.some(
|
||||||
|
(q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at'),
|
||||||
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -187,7 +191,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new SyncService(database, systemActorId);
|
const service = new SyncService(database, systemActorId);
|
||||||
await expect(service.authenticateDevice(rawToken, tenantA, 'attendance:sync')).rejects.toMatchObject({
|
await expect(
|
||||||
|
service.authenticateDevice(rawToken, tenantA, 'attendance:sync'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: 'SCOPE_INSUFFICIENT',
|
code: 'SCOPE_INSUFFICIENT',
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
});
|
});
|
||||||
|
|
@ -297,25 +303,15 @@ 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('returns structured conflict on revision mismatch for work_session', async () => {
|
it('rejects upsert_work_session command with COMMAND_NOT_SUPPORTED', 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-conflict',
|
idempotency_key: 'cmd-session-reject',
|
||||||
correlation_id: newUlid(),
|
correlation_id: newUlid(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
payload: {
|
payload: {
|
||||||
|
|
@ -323,43 +319,28 @@ 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: 2, // Client expects revision 2, but server has 3
|
revision: 1,
|
||||||
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('conflict');
|
expect(results[0]?.status).toBe('rejected');
|
||||||
expect(results[0]?.conflict).toEqual({
|
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||||
conflict_code: 'REVISION_MISMATCH',
|
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
||||||
entity_type: 'work_session',
|
|
||||||
entity_id: sessionId,
|
|
||||||
expected_revision: 2,
|
|
||||||
actual_revision: 3,
|
|
||||||
safe_description: 'Ревизия рабочей смены на сервере изменилась',
|
|
||||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns structured conflict on revision mismatch for timesheet_entry', async () => {
|
it('rejects upsert_timesheet_entry command with COMMAND_NOT_SUPPORTED', 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-conflict',
|
idempotency_key: 'cmd-ts-reject',
|
||||||
correlation_id: newUlid(),
|
correlation_id: newUlid(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
payload: {
|
payload: {
|
||||||
|
|
@ -367,22 +348,92 @@ 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, // Mismatch: expects 1, server has 4
|
revision: 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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('conflict');
|
expect(results[0]?.status).toBe('rejected');
|
||||||
expect(results[0]?.conflict).toEqual({
|
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||||
conflict_code: 'REVISION_MISMATCH',
|
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
||||||
entity_type: 'timesheet_entry',
|
});
|
||||||
entity_id: entryId,
|
|
||||||
expected_revision: 1,
|
it('does not write to staff_work_sessions, staff_timesheet_entries, or staff_sync_change_log on rejected shift commands', async () => {
|
||||||
actual_revision: 4,
|
const { database, queries } = createMockDb();
|
||||||
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
|
||||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
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 () => {
|
||||||
|
|
@ -468,7 +519,7 @@ describe('Sync Pull & Bootstrap Data Safety', () => {
|
||||||
const orgId = newUlid();
|
const orgId = newUlid();
|
||||||
const profId = newUlid();
|
const profId = newUlid();
|
||||||
|
|
||||||
const { database } = createMockDb((sql) => {
|
const { database, queries } = createMockDb((sql) => {
|
||||||
if (sql.includes('MAX(cursor_seq)')) {
|
if (sql.includes('MAX(cursor_seq)')) {
|
||||||
return { rows: [{ max_seq: '50' }] };
|
return { rows: [{ max_seq: '50' }] };
|
||||||
}
|
}
|
||||||
|
|
@ -502,6 +553,23 @@ 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();
|
||||||
|
|
@ -516,6 +584,56 @@ 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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,12 @@ describe('payroll foundation migration', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables and forces RLS with a tenant policy for every listed payroll table', () => {
|
it('enables and forces RLS with a tenant policy for every listed payroll table', () => {
|
||||||
expect(sql).toContain("EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table)");
|
expect(sql).toContain(
|
||||||
expect(sql).toContain("EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)");
|
"EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table)",
|
||||||
|
);
|
||||||
|
expect(sql).toContain(
|
||||||
|
"EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)",
|
||||||
|
);
|
||||||
expect(sql).toContain("target_table || '_tenant_isolation', target_table");
|
expect(sql).toContain("target_table || '_tenant_isolation', target_table");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -97,9 +101,13 @@ describe('payroll foundation migration', () => {
|
||||||
it('enforces tenant isolation and RLS on staff_payroll_ledger with select and insert policies', () => {
|
it('enforces tenant isolation and RLS on staff_payroll_ledger with select and insert policies', () => {
|
||||||
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY');
|
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY');
|
||||||
expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY');
|
expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY');
|
||||||
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger');
|
expect(sql).toContain(
|
||||||
|
'CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger',
|
||||||
|
);
|
||||||
expect(sql).toContain("FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))");
|
expect(sql).toContain("FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))");
|
||||||
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger');
|
expect(sql).toContain(
|
||||||
|
'CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger',
|
||||||
|
);
|
||||||
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
|
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
|
||||||
expect(sql).toContain("created_by = current_setting('app.user_id', true)");
|
expect(sql).toContain("created_by = current_setting('app.user_id', true)");
|
||||||
});
|
});
|
||||||
|
|
@ -165,9 +173,7 @@ describe('employee push notifications migration', () => {
|
||||||
expect(sql).toContain(
|
expect(sql).toContain(
|
||||||
'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE',
|
'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE',
|
||||||
);
|
);
|
||||||
expect(sql).toContain(
|
expect(sql).toContain('FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE');
|
||||||
'FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('grants required operations on both push tables to krona_staff_app', () => {
|
it('grants required operations on both push tables to krona_staff_app', () => {
|
||||||
|
|
@ -217,17 +223,27 @@ describe('offline sync backend migration', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('grants required operations on all sync tables and sequence to krona_staff_app', () => {
|
it('grants required operations on all sync tables and sequence to krona_staff_app', () => {
|
||||||
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_devices TO krona_staff_app;');
|
expect(sql).toContain(
|
||||||
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_change_log TO krona_staff_app;');
|
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_devices TO krona_staff_app;',
|
||||||
expect(sql).toContain('GRANT USAGE, SELECT ON SEQUENCE staff_sync_change_log_cursor_seq_seq TO krona_staff_app;');
|
);
|
||||||
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_command_inbox TO krona_staff_app;');
|
expect(sql).toContain(
|
||||||
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_device_checkpoints TO krona_staff_app;');
|
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_change_log TO krona_staff_app;',
|
||||||
|
);
|
||||||
|
expect(sql).toContain(
|
||||||
|
'GRANT USAGE, SELECT ON SEQUENCE staff_sync_change_log_cursor_seq_seq TO krona_staff_app;',
|
||||||
|
);
|
||||||
|
expect(sql).toContain(
|
||||||
|
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_command_inbox TO krona_staff_app;',
|
||||||
|
);
|
||||||
|
expect(sql).toContain(
|
||||||
|
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_device_checkpoints TO krona_staff_app;',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('declares and grants retention cleanup function', () => {
|
it('declares and grants retention cleanup function', () => {
|
||||||
expect(sql).toContain('CREATE OR REPLACE FUNCTION staff_sync_cleanup_retention');
|
expect(sql).toContain('CREATE OR REPLACE FUNCTION staff_sync_cleanup_retention');
|
||||||
expect(sql).toContain('GRANT EXECUTE ON FUNCTION staff_sync_cleanup_retention(INTEGER) TO krona_staff_app;');
|
expect(sql).toContain(
|
||||||
|
'GRANT EXECUTE ON FUNCTION staff_sync_cleanup_retention(INTEGER) TO krona_staff_app;',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,15 @@ const options = {
|
||||||
|
|
||||||
describe('attendance work-session reconciliation', () => {
|
describe('attendance work-session reconciliation', () => {
|
||||||
it('sums several in/out intervals without inventing time between them', () => {
|
it('sums several in/out intervals without inventing time between them', () => {
|
||||||
const result = reconcileWorkSession([
|
const result = reconcileWorkSession(
|
||||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
[
|
||||||
{ id: '2', type: 'check_out', occurredAt: '2026-09-24T05:00:00.000Z' },
|
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
||||||
{ id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' },
|
{ id: '2', type: 'check_out', occurredAt: '2026-09-24T05:00:00.000Z' },
|
||||||
{ id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' },
|
{ id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' },
|
||||||
], options);
|
{ id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' },
|
||||||
|
],
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
startedAt: '2026-09-24T01:00:00.000Z',
|
startedAt: '2026-09-24T01:00:00.000Z',
|
||||||
|
|
@ -25,28 +28,38 @@ describe('attendance work-session reconciliation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the first check-in and flags a repeated check-in', () => {
|
it('keeps the first check-in and flags a repeated check-in', () => {
|
||||||
const result = reconcileWorkSession([
|
const result = reconcileWorkSession(
|
||||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
[
|
||||||
{ id: '2', type: 'check_in', occurredAt: '2026-09-24T01:05:00.000Z' },
|
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
||||||
{ id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
|
{ id: '2', type: 'check_in', occurredAt: '2026-09-24T01:05:00.000Z' },
|
||||||
], options);
|
{ id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
|
||||||
|
],
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result?.workedMinutes).toBe(480);
|
expect(result?.workedMinutes).toBe(480);
|
||||||
expect(result?.anomalies).toEqual(['repeated_check_in']);
|
expect(result?.anomalies).toEqual(['repeated_check_in']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a recent unfinished shift open', () => {
|
it('keeps a recent unfinished shift open', () => {
|
||||||
const result = reconcileWorkSession([
|
const result = reconcileWorkSession(
|
||||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' },
|
[{ id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' }],
|
||||||
], options);
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 'open', endedAt: null, workedMinutes: 0, anomalies: [] });
|
expect(result).toMatchObject({
|
||||||
|
status: 'open',
|
||||||
|
endedAt: null,
|
||||||
|
workedMinutes: 0,
|
||||||
|
anomalies: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flags an old unfinished shift without estimating payable minutes', () => {
|
it('flags an old unfinished shift without estimating payable minutes', () => {
|
||||||
const result = reconcileWorkSession([
|
const result = reconcileWorkSession(
|
||||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
[{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' }],
|
||||||
], options);
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
status: 'pending_review',
|
status: 'pending_review',
|
||||||
|
|
@ -57,9 +70,10 @@ describe('attendance work-session reconciliation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-closes only after the scheduler cutoff and leaves an audit-worthy anomaly', () => {
|
it('auto-closes only after the scheduler cutoff and leaves an audit-worthy anomaly', () => {
|
||||||
const result = reconcileWorkSession([
|
const result = reconcileWorkSession(
|
||||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' },
|
[{ id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' }],
|
||||||
], { ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' });
|
{ ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' },
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
endedAt: '2026-09-23T19:00:00.000Z',
|
endedAt: '2026-09-23T19:00:00.000Z',
|
||||||
|
|
@ -70,9 +84,10 @@ describe('attendance work-session reconciliation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records a check-out without check-in as an anomaly with zero minutes', () => {
|
it('records a check-out without check-in as an anomaly with zero minutes', () => {
|
||||||
const result = reconcileWorkSession([
|
const result = reconcileWorkSession(
|
||||||
{ id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
|
[{ id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' }],
|
||||||
], options);
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
|
@ -84,10 +99,11 @@ describe('attendance work-session reconciliation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid timestamps and thresholds', () => {
|
it('rejects invalid timestamps and thresholds', () => {
|
||||||
expect(() => reconcileWorkSession([
|
expect(() =>
|
||||||
{ id: '1', type: 'check_in', occurredAt: 'not-a-date' },
|
reconcileWorkSession([{ id: '1', type: 'check_in', occurredAt: 'not-a-date' }], options),
|
||||||
], options)).toThrow('event 1 must be an ISO date-time');
|
).toThrow('event 1 must be an ISO date-time');
|
||||||
expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 }))
|
expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 })).toThrow(
|
||||||
.toThrow('openShiftThresholdMinutes must be a positive integer');
|
'openShiftThresholdMinutes must be a positive integer',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue