feat(sync): add offline sync backend foundation

- Add PostgreSQL migrations with strict RLS policies (0011)
- Introduce SyncService for handling auth, token-based registration
- Add payload size and replay attack mitigations
- Provide HTTP fastify routes (pull, push, ack, device management)
- Validate openapi contracts and add comprehensive tests
This commit is contained in:
Ochenstarik 2026-09-26 13:10:27 +07:00
parent 3e2054480c
commit 5ee75177b9
14 changed files with 3348 additions and 3 deletions

View file

@ -660,6 +660,271 @@ paths:
'204': { description: Подписка удалена } '204': { description: Подписка удалена }
'401': { description: Требуется авторизация } '401': { description: Требуется авторизация }
'403': { description: Недостаточно прав } '403': { description: Недостаточно прав }
/sync/devices:
post:
summary: Регистрация нового устройства синхронизации
operationId: registerSyncDevice
tags: [Sync]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_code, name]
properties:
device_code: { type: string, minLength: 1, maxLength: 64 }
name: { type: string, minLength: 1, maxLength: 255 }
org_unit_id: { $ref: '#/components/schemas/Ulid' }
allowed_scopes:
type: array
items: { type: string }
responses:
'201':
description: Устройство зарегистрировано, выдан токен
content:
application/json:
schema:
type: object
required: [device, token]
properties:
device:
type: object
required: [id, tenant_id, device_code, name, token_hint, status, allowed_scopes, registered_at, created_by]
properties:
id: { $ref: '#/components/schemas/Ulid' }
tenant_id: { $ref: '#/components/schemas/Ulid' }
device_code: { type: string }
name: { type: string }
token_hint: { type: string }
status: { type: string, enum: [active, revoked, pending] }
allowed_scopes: { type: array, items: { type: string } }
org_unit_id:
oneOf:
- { $ref: '#/components/schemas/Ulid' }
- { type: 'null' }
registered_at: { type: string, format: date-time }
created_by: { $ref: '#/components/schemas/Ulid' }
token: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { description: Требуется авторизация руководителя }
'403': { description: Недостаточно прав }
/sync/devices/{deviceId}/revoke:
post:
summary: Отозвать токен и доступ устройства
operationId: revokeSyncDevice
tags: [Sync]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: deviceId
required: true
schema: { $ref: '#/components/schemas/Ulid' }
responses:
'200':
description: Устройство отозвано
content:
application/json:
schema:
type: object
required: [id, status, revoked_at]
properties:
id: { $ref: '#/components/schemas/Ulid' }
status: { type: string, enum: [revoked] }
revoked_at: { type: string, format: date-time }
'401': { description: Требуется авторизация руководителя }
'403': { description: Недостаточно прав }
'404': { description: Устройство не найдено }
/sync/status:
get:
summary: Получить статус сервера синхронизации и чекпоинта устройства
operationId: getSyncStatus
tags: [Sync]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Текущее состояние синхронизации
content:
application/json:
schema:
type: object
required: [server_time_utc, latest_cursor, schema_version]
properties:
server_time_utc: { type: string, format: date-time }
latest_cursor: { type: string }
device_checkpoint:
oneOf:
- type: object
properties:
last_acked_cursor:
oneOf:
- { type: string }
- { type: 'null' }
acknowledged_at:
oneOf:
- { type: string, format: date-time }
- { type: 'null' }
- { type: 'null' }
schema_version: { type: integer }
'401': { description: Недействительный токен устройства }
'403': { description: Устройство отозвано или заблокировано }
/sync/pull:
post:
summary: Получить изменения сущностей после заданного курсора или начальный снимок
operationId: pullSyncChanges
tags: [Sync]
security: [{ bearerAuth: [] }]
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
cursor:
oneOf:
- { type: string }
- { type: 'null' }
limit: { type: integer, minimum: 1, maximum: 100, default: 100 }
entity_types:
type: array
items: { type: string }
responses:
'200':
description: Пакет изменений или начальный снимок
content:
application/json:
schema:
type: object
required: [changes, next_cursor, has_more]
properties:
changes:
type: array
items:
type: object
required: [cursor, entity_type, entity_id, operation, revision, payload, created_at]
properties:
cursor: { type: string }
entity_type: { type: string }
entity_id: { type: string }
operation: { type: string, enum: [upsert, delete] }
revision: { type: integer }
payload: { type: object }
created_at: { type: string, format: date-time }
next_cursor: { type: string }
has_more: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { description: Недействительный токен устройства }
'403': { description: Устройство отозвано или заблокировано }
/sync/push:
post:
summary: Пакетная отправка команд от локального устройства
operationId: pushSyncCommands
tags: [Sync]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [commands]
properties:
commands:
type: array
minItems: 1
maxItems: 100
items:
type: object
required: [command_type, schema_version, idempotency_key, correlation_id, created_at, payload]
properties:
command_type: { type: string }
schema_version: { type: integer }
idempotency_key: { type: string, minLength: 1, maxLength: 128 }
correlation_id: { type: string }
causation_id:
oneOf:
- { type: string }
- { type: 'null' }
created_at: { type: string, format: date-time }
payload: { type: object }
responses:
'200':
description: Результаты применения команд
content:
application/json:
schema:
type: object
required: [results]
properties:
results:
type: array
items:
type: object
required: [idempotency_key, command_type, status]
properties:
idempotency_key: { type: string }
command_type: { type: string }
status: { type: string, enum: [applied, rejected, conflict] }
result:
oneOf:
- { type: object }
- { type: 'null' }
conflict:
oneOf:
- type: object
required: [conflict_code, entity_type, entity_id, expected_revision, actual_revision, safe_description, client_action]
properties:
conflict_code: { type: string, enum: [REVISION_MISMATCH] }
entity_type: { type: string }
entity_id: { type: string }
expected_revision: { type: integer }
actual_revision: { type: integer }
safe_description: { type: string }
client_action: { type: string, enum: [FETCH_LATEST_AND_RETRY] }
- { type: 'null' }
error_code:
oneOf:
- { type: string }
- { type: 'null' }
error_message:
oneOf:
- { type: string }
- { type: 'null' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { description: Недействительный токен устройства }
'403': { description: Устройство отозвано или заблокировано }
/sync/ack:
post:
summary: Подтвердить получение и обработку изменений до курсора
operationId: ackSyncCheckpoint
tags: [Sync]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [cursor]
properties:
cursor: { type: string, minLength: 5 }
responses:
'200':
description: Чекпоинт зафиксирован
content:
application/json:
schema:
type: object
required: [acknowledged, cursor, acknowledged_at]
properties:
acknowledged: { type: boolean }
cursor: { type: string }
acknowledged_at: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { description: Недействительный токен устройства }
'403': { description: Устройство отозвано или заблокировано }
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:

View file

@ -0,0 +1,255 @@
# Протокол безопасной офлайн-синхронизации Krona Staff
## 1. Архитектурный контекст и инварианты
Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей:
1. **Единственный источник истины (Single Source of Truth)**: Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины.
2. **User != Employee**: Учётная запись пользователя платформы (`User`) отделена от карточки сотрудника (`Employee`).
3. **Строгая изоляция арендаторов (Tenant Isolation)**: Все сущности привязаны к `tenant_id` (ULID). Новые таблицы включают и форсируют PostgreSQL Row Level Security (`FORCE ROW LEVEL SECURITY`), гарантируя невозможность утечки данных между организациями даже на уровне прямого SQL.
4. **Безопасность логов**: Секретные токены, хэши паролей, биометрия и персональные данные (ПДН) никогда не попадают в серверные логи.
---
## 2. Жизненный цикл устройства и аутентификация
### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`)
Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`).
**Запрос:**
```json
{
"device_code": "TERM-MAIN-01",
"name": "Главный вход — Терминал",
"org_unit_id": "01J00000000000000000000001",
"allowed_scopes": ["attendance:sync"]
}
```
**Ответ (201 Created):**
```json
{
"device": {
"id": "01J9X1Z2A3B4C5D6E7F8G9H0J1",
"tenant_id": "01J00000000000000000000000",
"device_code": "TERM-MAIN-01",
"name": "Главный вход — Терминал",
"token_hint": "kstaff_dev_01J00...",
"status": "active",
"allowed_scopes": ["attendance:sync"],
"org_unit_id": "01J00000000000000000000001",
"registered_at": "2026-09-26T12:00:00.000Z",
"created_by": "01J00000000000000000000099"
},
"token": "kstaff_dev_01J00000000000000000000000_3f2a1b9c8d7e6f..."
}
```
> [!IMPORTANT]
> Сырой токен `token` возвращается клиенту ровно один раз при регистрации. В базе данных (`staff_sync_devices`) сохраняется исключительно криптографический хэш SHA-256 (`token_hash`) и безопасная маска (`token_hint`).
### 2.2. Аутентификация запросов устройства
Каждый запрос от локального терминала к API синхронизации должен содержать заголовок:
```http
Authorization: Bearer <device_token>
```
Сервер:
1. Вычисляет `SHA-256(device_token)`.
2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`.
3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`.
4. Находит устройство по `token_hash` в таблице `staff_sync_devices`.
5. Проверяет `status = 'active'` и наличие скоупа `attendance:sync`.
6. Асинхронно обновляет `last_seen_at`.
### 2.3. Отзыв устройства (`POST /api/v1/staff/sync/devices/:deviceId/revoke`)
При компрометации устройства, утере или списании администратор выполняет отзыв. Статус устройства в БД переводится в `revoked`, фиксируется `revoked_at = now()`.
Все последующие запросы с токеном отозванного устройства немедленно отклоняются со статусом **403 Forbidden** и кодом ошибки `DEVICE_REVOKED`:
```json
{
"statusCode": 403,
"error": "Forbidden",
"message": "Устройство отозвано"
}
```
---
## 3. Монотонный непрозрачный курсор изменений
### 3.1. Формат курсора
Курсор представляет собой непрозрачную строку вида:
```text
cur_<sequence>_<signature_hash>
```
Например: `cur_105_7a9f8b2c1d`.
- `<sequence>`: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`).
- `<signature_hash>`: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`SHA-256(tenant_id + seq + salt)`).
Свойства курсора:
- **Монотонность**: Гарантирует строго возрастающий порядок чтения журнала изменений.
- **Независимость от времени**: Не зависит от системных часов терминала и защищён от сдвигов локального времени.
- **Целостность и изоляция**: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой `INVALID_CURSOR` (400 Bad Request).
### 3.2. Начальная загрузка (Bootstrap, `cursor = null`)
Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет `POST /api/v1/staff/sync/pull` с пустым или отсутствующим курсором (`cursor: null`):
1. Сервер формирует снимок текущих **актуальных** данных для поддерживаемых сущностей (`org_unit`, `profession`, `employee`, `work_session`, `timesheet_entry`, `planned_shift`).
2. Для каждой сущности формируется безопасная полезная нагрузка с операцией `upsert`.
3. В ответе возвращается `next_cursor`, соответствующий текущему максимальному `cursor_seq` журнала изменений сервера.
4. После применения начального снимка терминал фиксирует `next_cursor` и переходит в режим инкрементального получения дельт.
### 3.3. Инкрементальная загрузка (Pull, `POST /api/v1/staff/sync/pull`)
Запрос:
```json
{
"cursor": "cur_105_7a9f8b2c1d",
"limit": 50,
"entity_types": ["employee", "work_session"]
}
```
Ответ:
```json
{
"changes": [
{
"cursor": "cur_106_3c4d5e6f7a",
"entity_type": "employee",
"entity_id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
"operation": "upsert",
"revision": 2,
"payload": {
"id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
"personnel_number": "EMP-042",
"display_name": "Иванов Иван Иванович",
"status": "active",
"profession_id": "01J00000000000000000000010",
"position": "Повар-универсал"
},
"created_at": "2026-09-26T12:05:00.000Z"
}
],
"next_cursor": "cur_106_3c4d5e6f7a",
"has_more": false
}
```
### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`)
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
```json
{
"cursor": "cur_106_3c4d5e6f7a"
}
```
Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
---
## 4. Пакетная отправка команд (Push) и идемпотентность
### 4.1. Контракт отправки (`POST /api/v1/staff/sync/push`)
Клиент отправляет накопленные в офлайне команды пачками.
- Ограничение пакета: от 1 до 100 команд (`BATCH_SIZE_EXCEEDED` при превышении).
- Максимальный размер полезной нагрузки: не более 64 КБ на команду (`PAYLOAD_TOO_LARGE`).
- Максимальный размер HTTP-запроса: 2 МБ.
- Временное окно защиты от Replay: `created_at` команды должно быть не старше 30 дней и не более чем на 5 минут в будущем (`TIMESTAMP_OUT_OF_WINDOW`).
**Пример запроса:**
```json
{
"commands": [
{
"command_type": "attendance_event",
"schema_version": 1,
"idempotency_key": "cmd-term01-778899",
"correlation_id": "01J9X1Z2A3B4C5D6E7F8G9H0AA",
"created_at": "2026-09-26T12:10:00.000Z",
"payload": {
"employee_id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
"event_type": "check_in",
"occurred_at": "2026-09-26T12:09:58.000Z",
"source_event_id": "scan-123456"
}
}
]
}
```
### 4.2. Идемпотентность (`staff_sync_command_inbox`)
Таблица `staff_sync_command_inbox` содержит уникальный ключ:
```sql
UNIQUE (tenant_id, device_id, idempotency_key)
```
При повторной отправке команды с тем же `idempotency_key`:
1. Мутация повторно не исполняется.
2. Сервер извлекает ранее зафиксированный результат (`status`, `result`, `conflict`, `error_code`, `error_message`) и возвращает его клиенту.
3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry).
---
## 5. Обнаружение конфликтов
Для сущностей с контролем версий (`work_session`, `timesheet_entry`) используется оптимистическая блокировка через поле `revision` (целое число > 0).
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
**Ответ с конфликтом (в составе `results`):**
```json
{
"idempotency_key": "cmd-term01-778900",
"command_type": "upsert_work_session",
"status": "conflict",
"conflict": {
"conflict_code": "REVISION_MISMATCH",
"entity_type": "work_session",
"entity_id": "01J9X1Z2A3B4C5D6E7F8G9H0WS",
"expected_revision": 2,
"actual_revision": 3,
"safe_description": "Ревизия рабочей смены на сервере изменилась",
"client_action": "FETCH_LATEST_AND_RETRY"
}
}
```
Клиент, получив конфликт, обязан:
1. Запросить актуальное состояние сущности через `POST /sync/pull`.
2. Обновить локальное представление.
3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
---
## 6. Минимальный безопасный набор данных
Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков:
| Сущность | Разрешённые поля | Запрещённые данные |
| :--- | :--- | :--- |
| `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры |
| `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады |
| `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` | Стоимость смены, начисления, штрафы |
| `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` | Плановая стоимость ФОТ смены |
| `attendance_event` | `id`, `employee_id`, `event_type`, `occurred_at`, `source_event_id`, `device_id`, `org_unit_id`, `status` | Сырые биометрические сэмплы сканера |
> [!CAUTION]
> **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!**
> Это исключает риск утечки конфиденциальной финансовой информации при физическом хищении терминала или компрометации его локального хранилища.
---
## 7. Безопасность биометрии
1. **Запрет передачи через Sync API**: Биометрические шаблоны (отпечатки пальцев, векторы лиц) строго запрещено передавать через интерфейсы синхронизации Krona Staff.
2. **Локальная верификация**: Если терминал оснащён биометрическим датчиком (например, ZKTeco SLK20R), он выполняет сопоставление биометрии локально в изолированном модуле или безопасном анклаве устройства.
3. **Фиксация только факта**: Сервер получает только сформированное событие `attendance_event` с проверенным `employee_id`, типом отметки (`check_in`/`check_out`) и временем фиксации.
---
## 8. Политика хранения данных (Retention)
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
```sql
SELECT * FROM staff_sync_cleanup_retention(30);
```
- Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30).
- Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней.
- Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap).

View file

@ -0,0 +1,5 @@
DROP FUNCTION IF EXISTS staff_sync_cleanup_retention(INTEGER);
DROP TABLE IF EXISTS staff_sync_device_checkpoints;
DROP TABLE IF EXISTS staff_sync_command_inbox;
DROP TABLE IF EXISTS staff_sync_change_log;
DROP TABLE IF EXISTS staff_sync_devices;

View file

@ -0,0 +1,141 @@
-- Offline synchronization backend foundation for Krona Staff local terminals and clients.
-- Protected by PostgreSQL Row Level Security (RLS) and strict tenant isolation.
CREATE TABLE staff_sync_devices (
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}$'),
device_code VARCHAR(64) NOT NULL,
name VARCHAR(255) NOT NULL,
token_hash VARCHAR(64) NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
token_hint VARCHAR(16) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'pending')),
allowed_scopes TEXT[] NOT NULL DEFAULT ARRAY['attendance:sync'],
org_unit_id VARCHAR(26),
registered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, device_code),
UNIQUE (tenant_id, token_hash),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL
);
CREATE INDEX staff_sync_devices_token_hash_idx
ON staff_sync_devices (tenant_id, token_hash);
CREATE INDEX staff_sync_devices_status_idx
ON staff_sync_devices (tenant_id, status);
ALTER TABLE staff_sync_devices ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_sync_devices FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_sync_devices_tenant_isolation
ON staff_sync_devices
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
CREATE TABLE staff_sync_change_log (
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}$'),
cursor_seq BIGSERIAL NOT NULL,
entity_type VARCHAR(64) NOT NULL,
entity_id VARCHAR(26) NOT NULL,
operation VARCHAR(20) NOT NULL CHECK (operation IN ('upsert', 'delete')),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id)
);
CREATE INDEX staff_sync_change_log_cursor_idx
ON staff_sync_change_log (tenant_id, cursor_seq);
CREATE INDEX staff_sync_change_log_entity_idx
ON staff_sync_change_log (tenant_id, entity_type, entity_id);
ALTER TABLE staff_sync_change_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_sync_change_log FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_sync_change_log_tenant_isolation
ON staff_sync_change_log
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
CREATE TABLE staff_sync_command_inbox (
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}$'),
device_id VARCHAR(26) NOT NULL,
command_type VARCHAR(64) NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
idempotency_key VARCHAR(128) NOT NULL,
correlation_id VARCHAR(36) NOT NULL,
causation_id VARCHAR(36),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status VARCHAR(20) NOT NULL CHECK (status IN ('applied', 'rejected', 'conflict')),
result JSONB,
conflict JSONB,
error_code VARCHAR(64),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, device_id, idempotency_key),
FOREIGN KEY (tenant_id, device_id) REFERENCES staff_sync_devices (tenant_id, id) ON DELETE CASCADE
);
CREATE INDEX staff_sync_command_inbox_device_idx
ON staff_sync_command_inbox (tenant_id, device_id, processed_at DESC);
ALTER TABLE staff_sync_command_inbox ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_sync_command_inbox FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_sync_command_inbox_tenant_isolation
ON staff_sync_command_inbox
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
CREATE TABLE staff_sync_device_checkpoints (
device_id VARCHAR(26) NOT NULL,
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
last_acked_cursor_seq BIGINT NOT NULL DEFAULT 0,
last_acked_cursor TEXT,
acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, device_id),
FOREIGN KEY (tenant_id, device_id) REFERENCES staff_sync_devices (tenant_id, id) ON DELETE CASCADE
);
ALTER TABLE staff_sync_device_checkpoints ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_sync_device_checkpoints FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_sync_device_checkpoints_tenant_isolation
ON staff_sync_device_checkpoints
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
CREATE OR REPLACE FUNCTION staff_sync_cleanup_retention(p_days INTEGER DEFAULT 30)
RETURNS TABLE(purged_change_logs BIGINT, purged_commands BIGINT)
LANGUAGE plpgsql
AS $$
DECLARE
v_change_logs BIGINT;
v_commands BIGINT;
BEGIN
WITH deleted_changes AS (
DELETE FROM staff_sync_change_log
WHERE created_at < now() - (p_days || ' days')::INTERVAL
RETURNING 1
)
SELECT count(*) INTO v_change_logs FROM deleted_changes;
WITH deleted_cmds AS (
DELETE FROM staff_sync_command_inbox
WHERE processed_at < now() - (p_days || ' days')::INTERVAL
RETURNING 1
)
SELECT count(*) INTO v_commands FROM deleted_cmds;
RETURN QUERY SELECT v_change_logs, v_commands;
END;
$$;
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_devices TO krona_staff_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_change_log TO krona_staff_app;
GRANT USAGE, SELECT ON SEQUENCE staff_sync_change_log_cursor_seq_seq TO krona_staff_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_command_inbox TO krona_staff_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_device_checkpoints TO krona_staff_app;
GRANT EXECUTE ON FUNCTION staff_sync_cleanup_retention(INTEGER) TO krona_staff_app;

View file

@ -30,7 +30,8 @@
"staff:payroll:calculate", "staff:payroll:calculate",
"staff:payroll:approve", "staff:payroll:approve",
"staff:payout:write", "staff:payout:write",
"staff:payslip:read_own" "staff:payslip:read_own",
"staff:sync:manage"
], ],
"entities": [ "entities": [
"Employee", "Employee",
@ -42,7 +43,8 @@
"PayRule", "PayRule",
"WorkSession", "WorkSession",
"PayrollRun", "PayrollRun",
"Payout" "Payout",
"SyncDevice"
], ],
"events_emitted": [ "events_emitted": [
"attendance.clock.in", "attendance.clock.in",

View file

@ -8,6 +8,7 @@ import { Database } from './infrastructure/database.js';
import { newUlid } from './core/ids.js'; import { newUlid } from './core/ids.js';
import { SERVICE_NAME, SERVICE_VERSION } from './version.js'; import { SERVICE_NAME, SERVICE_VERSION } from './version.js';
import { registerPortalRoutes } from './portal.js'; import { registerPortalRoutes } from './portal.js';
import { registerSyncRoutes } from './sync.js';
import { import {
portalCssEnhanced as portalCss, portalCssEnhanced as portalCss,
portalHtml, portalHtml,
@ -130,6 +131,7 @@ export async function buildApp({
); );
api.get('/openapi.json', async () => app.swagger()); api.get('/openapi.json', async () => app.swagger());
await registerPortalRoutes(api, config, database); await registerPortalRoutes(api, config, database);
await registerSyncRoutes(api, config, database);
}, },
{ prefix: apiPrefix }, { prefix: apiPrefix },
); );

51
src/domain/sync/cursor.ts Normal file
View file

@ -0,0 +1,51 @@
import crypto from 'node:crypto';
export class InvalidCursorError extends Error {
readonly code = 'INVALID_CURSOR';
constructor(message = 'Курсор синхронизации недействителен или повреждён') {
super(message);
this.name = 'InvalidCursorError';
}
}
const CURSOR_PREFIX = 'cur_';
const CURSOR_SALT = 'krona_staff_sync_cursor_salt_v1';
export function encodeCursor(tenantId: string, seq: bigint | number | string): string {
const seqStr = String(seq);
const hash = crypto
.createHash('sha256')
.update(`${tenantId}:${seqStr}:${CURSOR_SALT}`)
.digest('hex')
.slice(0, 12);
return `${CURSOR_PREFIX}${seqStr}_${hash}`;
}
export function decodeCursor(tenantId: string, cursor: string): bigint {
if (typeof cursor !== 'string' || !cursor) {
throw new InvalidCursorError('Курсор синхронизации не задан');
}
const match = cursor.match(/^cur_(\d+)_([0-9a-f]{12})$/);
if (!match) {
throw new InvalidCursorError('Неверный формат курсора синхронизации');
}
const seqStr = match[1]!;
const candidateHash = match[2]!;
const expectedHash = crypto
.createHash('sha256')
.update(`${tenantId}:${seqStr}:${CURSOR_SALT}`)
.digest('hex')
.slice(0, 12);
const candidateBuffer = Buffer.from(candidateHash, 'utf8');
const expectedBuffer = Buffer.from(expectedHash, 'utf8');
if (
candidateBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
) {
throw new InvalidCursorError('Подпись курсора синхронизации недействительна для данного арендатора');
}
return BigInt(seqStr);
}

1068
src/domain/sync/service.ts Normal file

File diff suppressed because it is too large Load diff

165
src/domain/sync/types.ts Normal file
View file

@ -0,0 +1,165 @@
import type { Ulid } from '../../core/ids.js';
export type SyncDeviceStatus = 'active' | 'revoked' | 'pending';
export type SyncOperation = 'upsert' | 'delete';
export type SyncCommandStatus = 'applied' | 'rejected' | 'conflict';
export interface SyncDevice {
id: Ulid;
tenant_id: Ulid;
device_code: string;
name: string;
token_hash: string;
token_hint: string;
status: SyncDeviceStatus;
allowed_scopes: string[];
org_unit_id: Ulid | null;
registered_at: string;
last_seen_at: string | null;
revoked_at: string | null;
created_by: Ulid;
}
export interface SyncDeviceRegistrationResult {
device: {
id: Ulid;
tenant_id: Ulid;
device_code: string;
name: string;
token_hint: string;
status: SyncDeviceStatus;
allowed_scopes: string[];
org_unit_id: Ulid | null;
registered_at: string;
created_by: Ulid;
};
token: string;
}
export interface SyncConflict {
conflict_code: 'REVISION_MISMATCH';
entity_type: string;
entity_id: string;
expected_revision: number;
actual_revision: number;
safe_description: string;
client_action: 'FETCH_LATEST_AND_RETRY';
}
export interface SyncCommand<P = Record<string, unknown>> {
command_type: string;
schema_version: number;
idempotency_key: string;
correlation_id: string;
causation_id?: string | null;
created_at: string;
payload: P;
}
export interface SyncCommandResult {
idempotency_key: string;
command_type: string;
status: SyncCommandStatus;
result?: Record<string, unknown> | null;
conflict?: SyncConflict | null;
error_code?: string | null;
error_message?: string | null;
}
export interface SyncChange<T = Record<string, unknown>> {
cursor: string;
entity_type: string;
entity_id: string;
operation: SyncOperation;
revision: number;
payload: T;
created_at: string;
}
export interface SyncStatus {
server_time_utc: string;
latest_cursor: string;
device_checkpoint: {
last_acked_cursor: string | null;
acknowledged_at: string | null;
} | null;
schema_version: number;
}
export interface SyncPullResult {
changes: SyncChange[];
next_cursor: string;
has_more: boolean;
}
export interface SyncAckResult {
acknowledged: boolean;
cursor: string;
acknowledged_at: string;
}
// Minimal safe payload schemas (no salary, no personal PII, no biometrics)
export interface SafeOrgUnitPayload {
id: string;
code: string;
name: string;
status: string;
}
export interface SafeProfessionPayload {
id: string;
code: string;
name: string;
status: string;
}
export interface SafeEmployeePayload {
id: string;
personnel_number: string;
display_name: string;
status: string;
profession_id: string | null;
position: string;
}
export interface SafeWorkSessionPayload {
id: string;
employee_id: string;
org_unit_id: string;
business_date: string;
started_at: string | null;
ended_at: string | null;
worked_minutes: number;
status: string;
revision: number;
}
export interface SafeTimesheetEntryPayload {
id: string;
employee_id: string;
work_date: string;
minutes_worked: number;
status: string;
revision: number;
}
export interface SafePlannedShiftPayload {
id: string;
employee_id: string;
org_unit_id: string;
business_date: string;
planned_minutes: number;
start_time: string | null;
end_time: string | null;
}
export interface SafeAttendanceEventPayload {
id: string;
employee_id: string;
event_type: 'check_in' | 'check_out';
occurred_at: string;
source_event_id: string;
device_id: string | null;
org_unit_id: string | null;
status: string;
}

457
src/sync.ts Normal file
View file

@ -0,0 +1,457 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { AppConfig } from './config.js';
import { parseUlid, type Ulid } from './core/ids.js';
import type { Database } from './infrastructure/database.js';
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
import type { IdentityPort } from './core/ports.js';
import {
SyncAuthError,
SyncService,
SyncValidationError,
} from './domain/sync/service.js';
import { InvalidCursorError } from './domain/sync/cursor.js';
import type { SyncCommand } from './domain/sync/types.js';
interface PullBody {
cursor?: string | null;
limit?: number;
entity_types?: string[];
}
interface PushBody {
commands: SyncCommand[];
}
interface AckBody {
cursor: string;
}
interface RegisterDeviceBody {
device_code: string;
name: string;
org_unit_id?: string | null;
allowed_scopes?: string[];
}
interface RevokeParams {
deviceId: string;
}
const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
export async function registerSyncRoutes(
api: FastifyInstance,
config: AppConfig,
database: Database,
) {
const baseIdentity: IdentityPort =
config.STAFF_RUNTIME_MODE === 'platform'
? new PlatformIdentityAdapter()
: new StandaloneIdentityAdapter(config);
const defaultActorId = parseUlid(config.STANDALONE_ACTOR_ID);
const syncService = new SyncService(database, defaultActorId);
const extractBearerToken = (request: FastifyRequest): string => {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new SyncAuthError('Требуется токен авторизации устройства', 'DEVICE_UNAUTHORIZED', 401);
}
return authHeader.slice(7).trim();
};
const resolveTenantId = (request: FastifyRequest): Ulid | undefined => {
const header = request.headers['x-tenant-id']?.toString();
if (header && ulidRegex.test(header)) {
return parseUlid(header);
}
if (config.STANDALONE_TENANT_ID && ulidRegex.test(config.STANDALONE_TENANT_ID)) {
return parseUlid(config.STANDALONE_TENANT_ID);
}
return undefined;
};
const requireManagerContext = async (request: FastifyRequest) => {
const authHeader = request.headers.authorization;
const cookieHeader = request.headers.cookie;
const sessionCookieName = '__Host-krona_staff_session';
let token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : undefined;
if (!token && cookieHeader) {
for (const part of cookieHeader.split(';')) {
const sep = part.indexOf('=');
if (sep > 0 && part.slice(0, sep).trim() === sessionCookieName) {
try {
token = decodeURIComponent(part.slice(sep + 1).trim());
} catch {
// ignore malformed cookie
}
break;
}
}
}
try {
const context = await baseIdentity.resolve(token, parseUlid(request.id));
if (
!context.permissions.has('staff:attendance:manage') &&
!context.permissions.has('staff:sync:manage')
) {
throw request.server.httpErrors.forbidden('Недостаточно прав для управления устройствами синхронизации');
}
return context;
} catch (error: unknown) {
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
throw error;
}
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
}
};
// 1. Device Registration (Manager endpoint)
api.post<{ Body: RegisterDeviceBody }>(
'/sync/devices',
{
schema: {
tags: ['Sync'],
summary: 'Регистрация нового устройства синхронизации',
body: {
type: 'object',
additionalProperties: false,
required: ['device_code', 'name'],
properties: {
device_code: { type: 'string', minLength: 1, maxLength: 64 },
name: { type: 'string', minLength: 1, maxLength: 255 },
org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
allowed_scopes: {
type: 'array',
items: { type: 'string' },
default: ['attendance:sync'],
},
},
},
response: {
201: {
type: 'object',
required: ['device', 'token'],
properties: {
device: {
type: 'object',
required: [
'id',
'tenant_id',
'device_code',
'name',
'token_hint',
'status',
'allowed_scopes',
'registered_at',
'created_by',
],
properties: {
id: { type: 'string' },
tenant_id: { type: 'string' },
device_code: { type: 'string' },
name: { type: 'string' },
token_hint: { type: 'string' },
status: { type: 'string' },
allowed_scopes: { type: 'array', items: { type: 'string' } },
org_unit_id: { type: 'string', nullable: true },
registered_at: { type: 'string' },
created_by: { type: 'string' },
},
},
token: { type: 'string' },
},
},
},
},
},
async (request, reply) => {
const manager = await requireManagerContext(request);
const result = await syncService.registerDevice(manager.tenantId, manager.actorId, request.body);
return reply.code(201).send(result);
},
);
// 2. Revoke Device (Manager endpoint)
api.post<{ Params: RevokeParams }>(
'/sync/devices/:deviceId/revoke',
{
schema: {
tags: ['Sync'],
summary: 'Отзыв доступа устройства синхронизации',
params: {
type: 'object',
additionalProperties: false,
required: ['deviceId'],
properties: {
deviceId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
},
},
response: {
200: {
type: 'object',
required: ['id', 'status', 'revoked_at'],
properties: {
id: { type: 'string' },
status: { type: 'string', const: 'revoked' },
revoked_at: { type: 'string' },
},
},
},
},
},
async (request, reply) => {
const manager = await requireManagerContext(request);
const deviceId = parseUlid(request.params.deviceId);
const result = await syncService.revokeDevice(manager.tenantId, manager.actorId, deviceId);
return reply.code(200).send(result);
},
);
// 3. Sync Status (Device endpoint)
api.get(
'/sync/status',
{
schema: {
tags: ['Sync'],
summary: 'Получить текущий статус синхронизации сервера и чекпоинта устройства',
response: {
200: {
type: 'object',
required: ['server_time_utc', 'latest_cursor', 'schema_version'],
properties: {
server_time_utc: { type: 'string' },
latest_cursor: { type: 'string' },
device_checkpoint: {
type: 'object',
nullable: true,
properties: {
last_acked_cursor: { type: 'string', nullable: true },
acknowledged_at: { type: 'string', nullable: true },
},
},
schema_version: { type: 'integer' },
},
},
},
},
},
async (request, reply) => {
try {
const token = extractBearerToken(request);
const tenantId = resolveTenantId(request);
const device = await syncService.authenticateDevice(token, tenantId);
const status = await syncService.getStatus(device.tenant_id, device.id);
return reply.code(200).send(status);
} catch (error) {
handleSyncError(error, request);
}
},
);
// 4. Sync Pull (Device endpoint)
api.post<{ Body: PullBody }>(
'/sync/pull',
{
schema: {
tags: ['Sync'],
summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
body: {
type: 'object',
additionalProperties: false,
properties: {
cursor: { type: 'string', nullable: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 100 },
entity_types: {
type: 'array',
items: { type: 'string' },
},
},
},
response: {
200: {
type: 'object',
required: ['changes', 'next_cursor', 'has_more'],
properties: {
changes: {
type: 'array',
items: {
type: 'object',
required: ['cursor', 'entity_type', 'entity_id', 'operation', 'revision', 'payload', 'created_at'],
properties: {
cursor: { type: 'string' },
entity_type: { type: 'string' },
entity_id: { type: 'string' },
operation: { type: 'string', enum: ['upsert', 'delete'] },
revision: { type: 'integer' },
payload: { type: 'object', additionalProperties: true },
created_at: { type: 'string' },
},
},
},
next_cursor: { type: 'string' },
has_more: { type: 'boolean' },
},
},
},
},
},
async (request, reply) => {
try {
const token = extractBearerToken(request);
const tenantId = resolveTenantId(request);
const device = await syncService.authenticateDevice(token, tenantId);
const pullResult = await syncService.pull(device.tenant_id, device.id, request.body ?? {});
return reply.code(200).send(pullResult);
} catch (error) {
handleSyncError(error, request);
}
},
);
// 5. Sync Push (Device endpoint)
api.post<{ Body: PushBody }>(
'/sync/push',
{
bodyLimit: 2097152, // 2MB max request size limit
schema: {
tags: ['Sync'],
summary: 'Пакетная отправка команд от устройства на сервер',
body: {
type: 'object',
additionalProperties: false,
required: ['commands'],
properties: {
commands: {
type: 'array',
minItems: 1,
maxItems: 100,
items: {
type: 'object',
required: ['command_type', 'schema_version', 'idempotency_key', 'correlation_id', 'created_at', 'payload'],
properties: {
command_type: { type: 'string' },
schema_version: { type: 'integer', default: 1 },
idempotency_key: { type: 'string', minLength: 1, maxLength: 128 },
correlation_id: { type: 'string' },
causation_id: { type: 'string', nullable: true },
created_at: { type: 'string' },
payload: { type: 'object', additionalProperties: true },
},
},
},
},
},
response: {
200: {
type: 'object',
required: ['results'],
properties: {
results: {
type: 'array',
items: {
type: 'object',
required: ['idempotency_key', 'command_type', 'status'],
properties: {
idempotency_key: { type: 'string' },
command_type: { type: 'string' },
status: { type: 'string', enum: ['applied', 'rejected', 'conflict'] },
result: { type: 'object', nullable: true, additionalProperties: true },
conflict: {
type: 'object',
nullable: true,
properties: {
conflict_code: { type: 'string' },
entity_type: { type: 'string' },
entity_id: { type: 'string' },
expected_revision: { type: 'integer' },
actual_revision: { type: 'integer' },
safe_description: { type: 'string' },
client_action: { type: 'string' },
},
},
error_code: { type: 'string', nullable: true },
error_message: { type: 'string', nullable: true },
},
},
},
},
},
},
},
},
async (request, reply) => {
try {
const token = extractBearerToken(request);
const tenantId = resolveTenantId(request);
const device = await syncService.authenticateDevice(token, tenantId);
const results = await syncService.push(device.tenant_id, device, request.body?.commands ?? []);
return reply.code(200).send({ results });
} catch (error) {
handleSyncError(error, request);
}
},
);
// 6. Sync Ack (Device endpoint)
api.post<{ Body: AckBody }>(
'/sync/ack',
{
schema: {
tags: ['Sync'],
summary: 'Подтверждение приёма и обработки изменений устройством до заданного курсора',
body: {
type: 'object',
additionalProperties: false,
required: ['cursor'],
properties: {
cursor: { type: 'string', minLength: 5 },
},
},
response: {
200: {
type: 'object',
required: ['acknowledged', 'cursor', 'acknowledged_at'],
properties: {
acknowledged: { type: 'boolean' },
cursor: { type: 'string' },
acknowledged_at: { type: 'string' },
},
},
},
},
},
async (request, reply) => {
try {
const token = extractBearerToken(request);
const tenantId = resolveTenantId(request);
const device = await syncService.authenticateDevice(token, tenantId);
const result = await syncService.acknowledge(device.tenant_id, device.id, request.body.cursor);
return reply.code(200).send(result);
} catch (error) {
handleSyncError(error, request);
}
},
);
}
function handleSyncError(error: unknown, request: FastifyRequest): never {
if (error instanceof SyncAuthError) {
if (error.statusCode === 403) {
throw request.server.httpErrors.forbidden(error.message);
}
throw request.server.httpErrors.unauthorized(error.message);
}
if (error instanceof InvalidCursorError) {
throw request.server.httpErrors.badRequest(error.message);
}
if (error instanceof SyncValidationError) {
if (error.statusCode === 404) {
throw request.server.httpErrors.notFound(error.message);
}
throw request.server.httpErrors.badRequest(error.message);
}
throw error;
}

View file

@ -53,7 +53,8 @@ describe('OpenAPI runtime contract', () => {
const runtimePath = path const runtimePath = path
.replace('{employeeId}', '01J00000000000000000000000') .replace('{employeeId}', '01J00000000000000000000000')
.replace('{workDate}', '2026-09-24') .replace('{workDate}', '2026-09-24')
.replace('{runId}', '01J00000000000000000000000'); .replace('{runId}', '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;
const method = methods.find((candidate) => candidate in operations); const method = methods.find((candidate) => candidate in operations);

View file

@ -16,6 +16,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
const employeeIdA = newUlid(); const employeeIdA = newUlid();
const subscriptionIdA = newUlid(); const subscriptionIdA = newUlid();
const deliveryIdA = newUlid(); const deliveryIdA = newUlid();
const deviceIdA = newUlid();
const changeIdA = newUlid();
const commandIdA = newUlid();
beforeAll(async () => { beforeAll(async () => {
const role = await database.pool.query( const role = await database.pool.query(
@ -26,6 +29,10 @@ describeDatabase('PostgreSQL tenant isolation', () => {
afterAll(async () => { afterAll(async () => {
await database.tenantTransaction(tenantA, actorA, async (client) => { await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]); await client.query('DELETE FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]);
await client.query('DELETE FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]); await client.query('DELETE FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]);
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]); await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]);
@ -161,5 +168,103 @@ describeDatabase('PostgreSQL tenant isolation', () => {
), ),
), ),
).rejects.toThrow(); ).rejects.toThrow();
// Offline sync devices tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, created_by)
VALUES ($1, $2, 'DEV-A', 'Terminal A', '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'kstaff_dev_test', 'active', $3)`,
[deviceIdA, tenantA, actorA],
);
});
const devVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_sync_devices WHERE id = $1', [deviceIdA]),
);
expect(devVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, created_by)
VALUES ($1, $2, 'DEV-B-FAIL', 'Fail Terminal', 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', 'hint', 'active', $3)`,
[newUlid(), tenantA, actorB],
),
),
).rejects.toThrow();
// Offline sync change log tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation, revision, payload)
VALUES ($1, $2, 'employee', $3, 'upsert', 1, '{}'::jsonb)`,
[changeIdA, tenantA, employeeIdA],
);
});
const changeVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_sync_change_log WHERE id = $1', [changeIdA]),
);
expect(changeVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation, revision, payload)
VALUES ($1, $2, 'employee', $3, 'upsert', 1, '{}'::jsonb)`,
[newUlid(), tenantA, employeeIdA],
),
),
).rejects.toThrow();
// Offline sync command inbox tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_sync_command_inbox
(id, tenant_id, device_id, command_type, schema_version, idempotency_key, correlation_id, payload, status, created_at)
VALUES ($1, $2, $3, 'attendance_event', 1, 'idemp-cmd-a', $4, '{}'::jsonb, 'applied', now())`,
[commandIdA, tenantA, deviceIdA, newUlid()],
);
});
const cmdVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_sync_command_inbox WHERE id = $1', [commandIdA]),
);
expect(cmdVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_sync_command_inbox
(id, tenant_id, device_id, command_type, schema_version, idempotency_key, correlation_id, payload, status, created_at)
VALUES ($1, $2, $3, 'attendance_event', 1, 'idemp-cmd-b', $4, '{}'::jsonb, 'applied', now())`,
[newUlid(), tenantA, deviceIdA, newUlid()],
),
),
).rejects.toThrow();
// Offline sync checkpoints tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor)
VALUES ($1, $2, 10, 'cur_10_test')`,
[tenantA, deviceIdA],
);
});
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]),
);
expect(cpVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor)
VALUES ($1, $2, 10, 'cur_10_fail')`,
[tenantA, deviceIdA],
),
),
).rejects.toThrow();
}); });
}); });

778
tests/unit/sync.test.ts Normal file
View file

@ -0,0 +1,778 @@
import crypto from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import { newUlid, parseUlid } from '../../src/core/ids.js';
import type { Database } from '../../src/infrastructure/database.js';
import { decodeCursor, encodeCursor, InvalidCursorError } from '../../src/domain/sync/cursor.js';
import { SyncService } from '../../src/domain/sync/service.js';
import type { SyncCommand } from '../../src/domain/sync/types.js';
import { buildApp } from '../../src/app.js';
import { loadConfig } from '../../src/config.js';
function createMockDb(queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }) {
const queries: Array<{ sql: string; params: unknown[] }> = [];
const query = vi.fn(async (sql: string, params: unknown[] = []) => {
queries.push({ sql, params });
if (queryHandler) {
const res = queryHandler(sql, params);
return { rows: res.rows, rowCount: res.rowCount ?? res.rows.length };
}
return { rows: [], rowCount: 0 };
});
const database = {
ping: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
tenantTransaction: vi.fn(
async (
_tenantId: string,
_actorId: string,
run: (client: { query: typeof query }) => Promise<unknown>,
) => run({ query }),
),
} as unknown as Database;
return { database, queries };
}
describe('Sync Cursor', () => {
const tenantA = parseUlid('01J0000000000000000000000A');
const tenantB = parseUlid('01J0000000000000000000000B');
it('encodes and decodes monotonic cursor correctly', () => {
const cursor = encodeCursor(tenantA, 42n);
expect(cursor).toMatch(/^cur_42_[0-9a-f]{12}$/);
const decoded = decodeCursor(tenantA, cursor);
expect(decoded).toBe(42n);
});
it('rejects cursor with invalid format or tampered signature', () => {
expect(() => decodeCursor(tenantA, 'invalid')).toThrowError(InvalidCursorError);
expect(() => decodeCursor(tenantA, 'cur_42')).toThrowError(InvalidCursorError);
expect(() => decodeCursor(tenantA, 'cur_abc_1234567890ab')).toThrowError(InvalidCursorError);
// Tampered seq with same signature
const valid = encodeCursor(tenantA, 42n);
const tampered = valid.replace('cur_42_', 'cur_43_');
expect(() => decodeCursor(tenantA, tampered)).toThrowError(InvalidCursorError);
});
it('enforces tenant isolation in cursor verification', () => {
const cursorA = encodeCursor(tenantA, 100n);
// Passing cursor of tenantA to tenantB must fail
expect(() => decodeCursor(tenantB, cursorA)).toThrowError(InvalidCursorError);
});
});
describe('Sync Device Authentication & Tenant Isolation', () => {
const tenantA = parseUlid('01J0000000000000000000000A');
const tenantB = parseUlid('01J0000000000000000000000B');
const systemActorId = parseUlid('01J00000000000000000000001');
it('authenticates active device and updates last_seen_at', async () => {
const deviceId = newUlid();
const rawSecret = crypto.randomBytes(32).toString('hex');
const rawToken = `kstaff_dev_${tenantA}_${rawSecret}`;
const tokenHash = SyncService.hashToken(rawToken);
const { database, queries } = createMockDb((sql) => {
if (sql.includes('SELECT id, tenant_id')) {
return {
rows: [
{
id: deviceId,
tenant_id: tenantA,
device_code: 'TERM-01',
name: 'Terminal 1',
token_hash: tokenHash,
token_hint: 'kstaff_dev_01J...',
status: 'active',
allowed_scopes: ['attendance:sync'],
org_unit_id: null,
registered_at: new Date('2026-09-26T10:00:00Z'),
last_seen_at: null,
revoked_at: null,
created_by: systemActorId,
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, systemActorId);
const device = await service.authenticateDevice(rawToken, tenantA);
expect(device.id).toBe(deviceId);
expect(device.tenant_id).toBe(tenantA);
expect(device.status).toBe('active');
expect(
queries.some((q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at')),
).toBe(true);
});
it('rejects revoked device with 403 DEVICE_REVOKED', async () => {
const rawToken = `kstaff_dev_${tenantA}_${crypto.randomBytes(32).toString('hex')}`;
const tokenHash = SyncService.hashToken(rawToken);
const { database } = createMockDb((sql) => {
if (sql.includes('SELECT id, tenant_id')) {
return {
rows: [
{
id: newUlid(),
tenant_id: tenantA,
device_code: 'TERM-01',
name: 'Terminal 1',
token_hash: tokenHash,
token_hint: 'hint',
status: 'revoked',
allowed_scopes: ['attendance:sync'],
org_unit_id: null,
registered_at: new Date(),
last_seen_at: null,
revoked_at: new Date(),
created_by: systemActorId,
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, systemActorId);
await expect(service.authenticateDevice(rawToken, tenantA)).rejects.toMatchObject({
name: 'SyncAuthError',
code: 'DEVICE_REVOKED',
statusCode: 403,
message: 'Устройство отозвано',
});
});
it('prevents reading or authenticating across different tenants (cross-tenant rejection)', async () => {
const rawToken = `kstaff_dev_${tenantA}_${crypto.randomBytes(32).toString('hex')}`;
const { database } = createMockDb();
const service = new SyncService(database, systemActorId);
// Explicitly providing tenantB for a token configured for tenantA
await expect(service.authenticateDevice(rawToken, tenantB)).rejects.toMatchObject({
code: 'TENANT_MISMATCH',
statusCode: 403,
});
});
it('rejects device without required sync scope', async () => {
const rawToken = `kstaff_dev_${tenantA}_${crypto.randomBytes(32).toString('hex')}`;
const { database } = createMockDb((sql) => {
if (sql.includes('SELECT id, tenant_id')) {
return {
rows: [
{
id: newUlid(),
tenant_id: tenantA,
device_code: 'TERM-VIEWER',
name: 'Viewer',
token_hash: SyncService.hashToken(rawToken),
token_hint: 'hint',
status: 'active',
allowed_scopes: ['display:read_only'],
org_unit_id: null,
registered_at: new Date(),
last_seen_at: null,
revoked_at: null,
created_by: systemActorId,
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, systemActorId);
await expect(service.authenticateDevice(rawToken, tenantA, 'attendance:sync')).rejects.toMatchObject({
code: 'SCOPE_INSUFFICIENT',
statusCode: 403,
});
});
});
describe('Sync Push & Idempotency', () => {
const tenantId = parseUlid('01J0000000000000000000000A');
const systemActorId = parseUlid('01J00000000000000000000001');
const deviceId = parseUlid('01J0000000000000000000000D');
const employeeId = parseUlid('01J0000000000000000000000E');
const testDevice = {
id: deviceId,
tenant_id: tenantId,
device_code: 'TERM-01',
name: 'Terminal 1',
token_hash: 'hash',
token_hint: 'hint',
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: systemActorId,
};
it('processes attendance_event and inserts into staff_sync_command_inbox', async () => {
const { database, queries } = createMockDb((sql) => {
if (sql.includes('SELECT status, result, conflict')) {
return { rows: [] }; // Not yet in inbox
}
if (sql.includes('SELECT id FROM staff_employees')) {
return { rows: [{ id: employeeId }] };
}
return { rows: [] };
});
const service = new SyncService(database, systemActorId);
const command: SyncCommand = {
command_type: 'attendance_event',
schema_version: 1,
idempotency_key: 'cmd-test-1',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
employee_id: employeeId,
event_type: 'check_in',
occurred_at: new Date().toISOString(),
source_event_id: 'scanner-evt-001',
},
};
const results = await service.push(tenantId, testDevice, [command]);
expect(results).toHaveLength(1);
expect(results[0]?.status).toBe('applied');
expect(results[0]?.result).toMatchObject({ status: 'accepted' });
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(true);
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_sync_command_inbox'))).toBe(true);
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_sync_change_log'))).toBe(true);
});
it('returns cached result on repeated idempotency_key without re-executing', async () => {
const cachedResult = { event_id: newUlid(), status: 'accepted' };
const { database, queries } = createMockDb((sql) => {
if (sql.includes('SELECT status, result, conflict')) {
// Return already processed command
return {
rows: [
{
status: 'applied',
result: cachedResult,
conflict: null,
error_code: null,
error_message: null,
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, systemActorId);
const command: SyncCommand = {
command_type: 'attendance_event',
schema_version: 1,
idempotency_key: 'cmd-duplicate',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
employee_id: employeeId,
event_type: 'check_in',
occurred_at: new Date().toISOString(),
source_event_id: 'scanner-evt-001',
},
};
const results = await service.push(tenantId, testDevice, [command]);
expect(results).toHaveLength(1);
expect(results[0]?.status).toBe('applied');
expect(results[0]?.result).toEqual(cachedResult);
// Did NOT execute insert into staff_attendance_events again!
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 () => {
const sessionId = newUlid();
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 command: SyncCommand = {
command_type: 'upsert_work_session',
schema_version: 1,
idempotency_key: 'cmd-session-conflict',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
id: sessionId,
employee_id: employeeId,
org_unit_id: newUlid(),
business_date: '2026-09-26',
revision: 2, // Client expects revision 2, but server has 3
worked_minutes: 480,
},
};
const results = await service.push(tenantId, testDevice, [command]);
expect(results).toHaveLength(1);
expect(results[0]?.status).toBe('conflict');
expect(results[0]?.conflict).toEqual({
conflict_code: 'REVISION_MISMATCH',
entity_type: 'work_session',
entity_id: sessionId,
expected_revision: 2,
actual_revision: 3,
safe_description: 'Ревизия рабочей смены на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
});
});
it('returns structured conflict on revision mismatch for timesheet_entry', async () => {
const entryId = newUlid();
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 command: SyncCommand = {
command_type: 'upsert_timesheet_entry',
schema_version: 1,
idempotency_key: 'cmd-ts-conflict',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
id: entryId,
employee_id: employeeId,
work_date: '2026-09-26',
minutes_worked: 500,
revision: 1, // Mismatch: expects 1, server has 4
},
};
const results = await service.push(tenantId, testDevice, [command]);
expect(results).toHaveLength(1);
expect(results[0]?.status).toBe('conflict');
expect(results[0]?.conflict).toEqual({
conflict_code: 'REVISION_MISMATCH',
entity_type: 'timesheet_entry',
entity_id: entryId,
expected_revision: 1,
actual_revision: 4,
safe_description: 'Ревизия записи табеля на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
});
});
it('enforces batch size limits and payload size limits', async () => {
const { database } = createMockDb();
const service = new SyncService(database, systemActorId);
// Empty batch
await expect(service.push(tenantId, testDevice, [])).rejects.toMatchObject({
code: 'BATCH_EMPTY',
statusCode: 400,
});
// Batch > 100
const largeBatch: SyncCommand[] = Array.from({ length: 101 }, (_, i) => ({
command_type: 'attendance_event',
schema_version: 1,
idempotency_key: `key-${i}`,
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {},
}));
await expect(service.push(tenantId, testDevice, largeBatch)).rejects.toMatchObject({
code: 'BATCH_SIZE_EXCEEDED',
statusCode: 400,
});
// Single payload > 64KB
const giantPayload = { data: 'x'.repeat(70000) };
const commandWithGiantPayload: SyncCommand = {
command_type: 'attendance_event',
schema_version: 1,
idempotency_key: 'giant-payload',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: giantPayload,
};
const results = await service.push(tenantId, testDevice, [commandWithGiantPayload]);
expect(results[0]?.status).toBe('rejected');
expect(results[0]?.error_code).toBe('PAYLOAD_TOO_LARGE');
});
it('rejects commands with timestamps outside the replay window', async () => {
const { database } = createMockDb();
const service = new SyncService(database, systemActorId);
// 40 days old (> 30 days)
const oldDate = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString();
const oldCmd: SyncCommand = {
command_type: 'attendance_event',
schema_version: 1,
idempotency_key: 'cmd-old',
correlation_id: newUlid(),
created_at: oldDate,
payload: {},
};
// 10 minutes in future (> 5 minutes)
const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString();
const futureCmd: SyncCommand = {
command_type: 'attendance_event',
schema_version: 1,
idempotency_key: 'cmd-future',
correlation_id: newUlid(),
created_at: futureDate,
payload: {},
};
const results = await service.push(tenantId, testDevice, [oldCmd, futureCmd]);
expect(results[0]?.status).toBe('rejected');
expect(results[0]?.error_code).toBe('TIMESTAMP_OUT_OF_WINDOW');
expect(results[1]?.status).toBe('rejected');
expect(results[1]?.error_code).toBe('TIMESTAMP_OUT_OF_WINDOW');
});
});
describe('Sync Pull & Bootstrap Data Safety', () => {
const tenantId = parseUlid('01J0000000000000000000000A');
const systemActorId = parseUlid('01J00000000000000000000001');
const deviceId = parseUlid('01J0000000000000000000000D');
it('bootstrap (cursor = null) returns safe entities without salary, PII, or biometrics', async () => {
const empId = newUlid();
const orgId = newUlid();
const profId = newUlid();
const { database } = createMockDb((sql) => {
if (sql.includes('MAX(cursor_seq)')) {
return { rows: [{ max_seq: '50' }] };
}
if (sql.includes('FROM staff_org_units')) {
return { rows: [{ id: orgId, code: 'KITCHEN', name: 'Кухня', status: 'active' }] };
}
if (sql.includes('FROM staff_professions')) {
return { rows: [{ id: profId, code: 'CHEF', name: 'Шеф', status: 'active' }] };
}
if (sql.includes('FROM staff_employees')) {
return {
rows: [
{
id: empId,
personnel_number: 'EMP-01',
display_name: 'Петров Петр',
status: 'active',
profession_id: profId,
position: 'Шеф-повар',
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, systemActorId);
const result = await service.pull(tenantId, deviceId, { cursor: null });
expect(result.changes).toHaveLength(3);
expect(result.next_cursor).toBe(encodeCursor(tenantId, 50n));
expect(result.has_more).toBe(false);
// Verify employee payload has NO sensitive fields
const empChange = result.changes.find((c) => c.entity_type === 'employee');
expect(empChange).toBeDefined();
const payload = empChange!.payload;
expect(payload).toHaveProperty('personnel_number');
expect(payload).toHaveProperty('display_name');
expect(payload).not.toHaveProperty('salary');
expect(payload).not.toHaveProperty('hourly_rate');
expect(payload).not.toHaveProperty('passport');
expect(payload).not.toHaveProperty('phone');
expect(payload).not.toHaveProperty('biometrics');
expect(payload).not.toHaveProperty('template');
});
it('incremental pull returns changes strictly after cursor', async () => {
const cursor = encodeCursor(tenantId, 10n);
const { database } = createMockDb((sql) => {
if (sql.includes('FROM staff_sync_change_log')) {
return {
rows: [
{
id: newUlid(),
cursor_seq: '11',
entity_type: 'employee',
entity_id: newUlid(),
operation: 'upsert',
revision: 2,
payload: { id: 'test', display_name: 'Тест' },
created_at: new Date('2026-09-26T12:00:00Z'),
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, systemActorId);
const result = await service.pull(tenantId, deviceId, { cursor });
expect(result.changes).toHaveLength(1);
expect(result.changes[0]?.cursor).toBe(encodeCursor(tenantId, 11n));
expect(result.next_cursor).toBe(encodeCursor(tenantId, 11n));
expect(result.has_more).toBe(false);
});
});
describe('Sync API HTTP Endpoints', () => {
const tenantId = '01J00000000000000000000000';
const managerToken = 'a'.repeat(64);
const config = loadConfig({
DATABASE_URL: 'postgres://localhost/staff',
STANDALONE_TENANT_ID: tenantId,
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
STANDALONE_MANAGER_TOKEN: managerToken,
STANDALONE_TERMINAL_TOKEN: 'b'.repeat(64),
LOG_LEVEL: 'silent',
NODE_ENV: 'test',
});
it('registers device with manager authorization', async () => {
const deviceId = newUlid();
const { database } = createMockDb((sql) => {
if (sql.includes('INSERT INTO staff_sync_devices')) {
return {
rows: [
{
id: deviceId,
tenant_id: tenantId,
device_code: 'TERM-API-01',
name: 'Касса 1',
token_hint: 'kstaff_dev_...',
status: 'active',
allowed_scopes: ['attendance:sync'],
org_unit_id: null,
registered_at: new Date(),
created_by: config.STANDALONE_ACTOR_ID,
},
],
};
}
return { rows: [] };
});
const app = await buildApp({ config, database });
await app.ready();
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/sync/devices',
headers: {
authorization: `Bearer ${managerToken}`,
},
payload: {
device_code: 'TERM-API-01',
name: 'Касса 1',
},
});
expect(response.statusCode).toBe(201);
const body = response.json<{ device: { device_code: string }; token: string }>();
expect(body.device.device_code).toBe('TERM-API-01');
expect(body.token).toMatch(/^kstaff_dev_/);
await app.close();
});
it('rejects unauthenticated requests to device sync endpoints', async () => {
const { database } = createMockDb();
const app = await buildApp({ config, database });
await app.ready();
const response = await app.inject({
method: 'GET',
url: '/api/v1/staff/sync/status',
});
expect(response.statusCode).toBe(401);
await app.close();
});
it('handles checkpoint acknowledgment', async () => {
const rawToken = `kstaff_dev_${tenantId}_${crypto.randomBytes(32).toString('hex')}`;
const deviceId = newUlid();
const cursor = encodeCursor(tenantId, 55n);
const { database } = createMockDb((sql) => {
if (sql.includes('FROM staff_sync_devices')) {
return {
rows: [
{
id: deviceId,
tenant_id: tenantId,
device_code: 'TERM-01',
name: 'Term',
token_hash: SyncService.hashToken(rawToken),
token_hint: 'hint',
status: 'active',
allowed_scopes: ['attendance:sync'],
org_unit_id: null,
registered_at: new Date(),
last_seen_at: null,
revoked_at: null,
created_by: config.STANDALONE_ACTOR_ID,
},
],
};
}
if (sql.includes('INSERT INTO staff_sync_device_checkpoints')) {
return { rows: [{ acknowledged_at: new Date('2026-09-26T12:00:00Z') }] };
}
return { rows: [] };
});
const app = await buildApp({ config, database });
await app.ready();
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/sync/ack',
headers: {
authorization: `Bearer ${rawToken}`,
},
payload: {
cursor,
},
});
expect(response.statusCode).toBe(200);
const body = response.json<{ acknowledged: boolean; cursor: string }>();
expect(body.acknowledged).toBe(true);
expect(body.cursor).toBe(cursor);
await app.close();
});
});
describe('Security & Privacy Audit (No secrets, no PII, no biometrics in logs or state)', () => {
const tenantId = parseUlid('01J0000000000000000000000A');
const actorId = parseUlid('01J00000000000000000000001');
it('guarantees raw device token is never stored in the database, only SHA-256 hash', async () => {
const { database, queries } = createMockDb((sql) => {
if (sql.includes('INSERT INTO staff_sync_devices')) {
return {
rows: [
{
id: newUlid(),
tenant_id: tenantId,
device_code: 'DEV-SEC',
name: 'Secure Terminal',
token_hint: 'kstaff_dev_...',
status: 'active',
allowed_scopes: ['attendance:sync'],
org_unit_id: null,
registered_at: new Date(),
created_by: actorId,
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, actorId);
const regResult = await service.registerDevice(tenantId, actorId, {
device_code: 'DEV-SEC',
name: 'Secure Terminal',
});
const rawToken = regResult.token;
expect(rawToken).toMatch(/^kstaff_dev_/);
// Verify SQL query parameters
const insertQuery = queries.find((q) => q.sql.includes('INSERT INTO staff_sync_devices'));
expect(insertQuery).toBeDefined();
// Raw token MUST NOT be present in parameters
const paramsString = JSON.stringify(insertQuery!.params);
expect(paramsString).not.toContain(rawToken);
// Only the hash should be stored
const expectedHash = SyncService.hashToken(rawToken);
expect(paramsString).toContain(expectedHash);
});
it('sanitizes and excludes sensitive employee attributes from sync payloads', async () => {
const { database } = createMockDb((sql) => {
if (sql.includes('MAX(cursor_seq)')) {
return { rows: [{ max_seq: '1' }] };
}
if (sql.includes('FROM staff_employees')) {
return {
rows: [
{
id: newUlid(),
personnel_number: 'EMP-99',
display_name: 'Секретный Сотрудник',
status: 'active',
profession_id: null,
position: 'Аналитик',
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, actorId);
const result = await service.pull(tenantId, newUlid(), { cursor: null });
const serialized = JSON.stringify(result);
// Explicit forbidden terms check
const forbiddenTerms = [
'salary',
'wage',
'rate',
'passport',
'snils',
'inn',
'bank_account',
'card_number',
'phone',
'email',
'biometrics',
'fingerprint',
'face_descriptor',
];
for (const term of forbiddenTerms) {
expect(serialized.toLowerCase()).not.toContain(`"${term}"`);
}
});
});

View file

@ -180,4 +180,54 @@ describe('employee push notifications migration', () => {
}); });
}); });
describe('offline sync backend migration', () => {
const sql = readFileSync('migrations/0011_offline_sync_backend.up.sql', 'utf8');
const tables = [
'staff_sync_devices',
'staff_sync_change_log',
'staff_sync_command_inbox',
'staff_sync_device_checkpoints',
];
it.each(tables)('%s is tenant scoped and forces RLS', (table) => {
const create = sql.slice(
sql.indexOf(`CREATE TABLE ${table}`),
sql.indexOf(';', sql.indexOf(`CREATE TABLE ${table}`)),
);
expect(create).toContain('tenant_id VARCHAR(26) NOT NULL');
expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
expect(sql).toContain(`CREATE POLICY ${table}_tenant_isolation`);
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
});
it('declares uniqueness, cursor sequencing and idempotency constraints with tenant scope', () => {
expect(sql).toContain('UNIQUE (tenant_id, id)');
expect(sql).toContain('UNIQUE (tenant_id, device_code)');
expect(sql).toContain('UNIQUE (tenant_id, token_hash)');
expect(sql).toContain('UNIQUE (tenant_id, device_id, idempotency_key)');
expect(sql).toContain('PRIMARY KEY (tenant_id, device_id)');
expect(sql).toContain('cursor_seq BIGSERIAL NOT NULL');
});
it('declares foreign key cascades for device ownership', () => {
expect(sql).toContain(
'FOREIGN KEY (tenant_id, device_id) REFERENCES staff_sync_devices (tenant_id, id) ON DELETE CASCADE',
);
});
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('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', () => {
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;');
});
});