Предотвращение несанкционированного создания подтверждённых смен и начислений с устройств синхронизации. Команды upsert_work_session и upsert_timesheet_entry отклоняются сервером со статусом rejected и кодом COMMAND_NOT_SUPPORTED без записи в базу данных.
This commit is contained in:
parent
778ba2d15b
commit
795bba0ce8
5 changed files with 444 additions and 290 deletions
|
|
@ -691,7 +691,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 +814,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 +849,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 +894,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 +914,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' }
|
||||||
|
|
|
||||||
|
|
@ -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. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
|
||||||
|
|
@ -221,7 +258,7 @@ 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` | **Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия** |
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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,7 +191,8 @@ 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.length > 0
|
||||||
? params.allowed_scopes
|
? params.allowed_scopes
|
||||||
: ['attendance:sync'];
|
: ['attendance:sync'];
|
||||||
|
|
||||||
|
|
@ -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]!;
|
||||||
|
|
@ -326,7 +341,8 @@ export class SyncService {
|
||||||
// 1. If cursor is provided -> incremental pull from staff_sync_change_log
|
// 1. If cursor is provided -> incremental pull from staff_sync_change_log
|
||||||
if (options.cursor) {
|
if (options.cursor) {
|
||||||
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 +376,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,
|
||||||
|
|
@ -380,16 +395,22 @@ 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;
|
||||||
[tenantId],
|
code: string;
|
||||||
);
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}>(`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`, [
|
||||||
|
tenantId,
|
||||||
|
]);
|
||||||
for (const row of orgRes.rows) {
|
for (const row of orgRes.rows) {
|
||||||
const payload: SafeOrgUnitPayload = {
|
const payload: SafeOrgUnitPayload = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
@ -411,7 +432,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],
|
||||||
);
|
);
|
||||||
|
|
@ -662,7 +688,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);
|
||||||
|
|
@ -757,7 +784,17 @@ export class SyncService {
|
||||||
(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)
|
||||||
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
|
||||||
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
|
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
|
||||||
[eventId, tenantId, employeeId, eventType, sourceEventId, occurredAt, device.id, device.id, correlationId],
|
[
|
||||||
|
eventId,
|
||||||
|
tenantId,
|
||||||
|
employeeId,
|
||||||
|
eventType,
|
||||||
|
sourceEventId,
|
||||||
|
occurredAt,
|
||||||
|
device.id,
|
||||||
|
device.id,
|
||||||
|
correlationId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const safePayload: SafeAttendanceEventPayload = {
|
const safePayload: SafeAttendanceEventPayload = {
|
||||||
|
|
@ -771,7 +808,15 @@ export class SyncService {
|
||||||
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 +826,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 {
|
return {
|
||||||
idempotency_key: cmd.idempotency_key,
|
idempotency_key: cmd.idempotency_key,
|
||||||
command_type: cmd.command_type,
|
command_type: cmd.command_type,
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
error_code: 'INVALID_TIMESHEET_PAYLOAD',
|
error_code: 'COMMAND_NOT_SUPPORTED',
|
||||||
error_message: 'employee_id и work_date обязательны',
|
error_message:
|
||||||
};
|
'Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено',
|
||||||
}
|
|
||||||
|
|
||||||
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
|
|
||||||
`SELECT id, revision, status FROM staff_timesheet_entries WHERE tenant_id = $1 AND id = $2`,
|
|
||||||
[tenantId, entryId],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingRes.rows.length > 0) {
|
|
||||||
const existing = existingRes.rows[0]!;
|
|
||||||
if (existing.revision !== clientRevision) {
|
|
||||||
const conflict: SyncConflict = {
|
|
||||||
conflict_code: 'REVISION_MISMATCH',
|
|
||||||
entity_type: 'timesheet_entry',
|
|
||||||
entity_id: entryId,
|
|
||||||
expected_revision: clientRevision,
|
|
||||||
actual_revision: existing.revision,
|
|
||||||
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
|
||||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
idempotency_key: cmd.idempotency_key,
|
|
||||||
command_type: cmd.command_type,
|
|
||||||
status: 'conflict',
|
|
||||||
conflict,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextRev = existing.revision + 1;
|
|
||||||
await client.query(
|
|
||||||
`UPDATE staff_timesheet_entries
|
|
||||||
SET minutes_worked = $1, status = $2, revision = $3, updated_at = now()
|
|
||||||
WHERE tenant_id = $4 AND id = $5`,
|
|
||||||
[minutesWorked, tsStatus, nextRev, tenantId, entryId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const safePayload: SafeTimesheetEntryPayload = {
|
|
||||||
id: entryId,
|
|
||||||
employee_id: employeeId,
|
|
||||||
work_date: workDate,
|
|
||||||
minutes_worked: minutesWorked,
|
|
||||||
status: tsStatus,
|
|
||||||
revision: nextRev,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', nextRev, safePayload);
|
|
||||||
|
|
||||||
return {
|
|
||||||
idempotency_key: cmd.idempotency_key,
|
|
||||||
command_type: cmd.command_type,
|
|
||||||
status: 'applied',
|
|
||||||
result: { id: entryId, revision: nextRev, status: 'applied' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRev = clientRevision;
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO staff_timesheet_entries
|
|
||||||
(id, tenant_id, employee_id, work_date, minutes_worked, status, revision, updated_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
||||||
[entryId, tenantId, employeeId, workDate, minutesWorked, tsStatus, newRev, device.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const safePayload: SafeTimesheetEntryPayload = {
|
|
||||||
id: entryId,
|
|
||||||
employee_id: employeeId,
|
|
||||||
work_date: workDate,
|
|
||||||
minutes_worked: minutesWorked,
|
|
||||||
status: tsStatus,
|
|
||||||
revision: newRev,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', newRev, safePayload);
|
|
||||||
|
|
||||||
return {
|
|
||||||
idempotency_key: cmd.idempotency_key,
|
|
||||||
command_type: cmd.command_type,
|
|
||||||
status: 'applied',
|
|
||||||
result: { id: entryId, revision: newRev, status: 'applied' },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1052,8 +905,13 @@ 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,
|
||||||
|
): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
||||||
|
return this.database.tenantTransaction(
|
||||||
|
this.systemActorId,
|
||||||
|
this.systemActorId,
|
||||||
|
async (client) => {
|
||||||
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
||||||
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
||||||
[retentionDays],
|
[retentionDays],
|
||||||
|
|
@ -1063,6 +921,7 @@ export class SyncService {
|
||||||
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
||||||
purged_commands: Number(row?.purged_commands ?? 0),
|
purged_commands: Number(row?.purged_commands ?? 0),
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
173
tests/integration/sync-rejection.test.ts
Normal file
173
tests/integration/sync-rejection.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { Database } from '../../src/infrastructure/database.js';
|
||||||
|
import { newUlid, parseUlid } 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 describeDatabase = connectionString ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
|
||||||
|
const database = new Database(connectionString!);
|
||||||
|
const tenantId = parseUlid('01J0000000000000000000000A');
|
||||||
|
const actorId = parseUlid('01J00000000000000000000001');
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await database.tenantTransaction(tenantId, actorId, async (client) => {
|
||||||
|
// Create employee
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status, created_by)
|
||||||
|
VALUES ($1, $2, 'EMP-REJ-01', 'Тестовый Сотрудник', 'active', $3)`,
|
||||||
|
[employeeId, tenantId, actorId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 () => {
|
||||||
|
await database.tenantTransaction(tenantId, actorId, async (client) => {
|
||||||
|
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantId]);
|
||||||
|
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantId]);
|
||||||
|
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantId]);
|
||||||
|
await client.query('DELETE FROM staff_work_sessions WHERE tenant_id = $1', [tenantId]);
|
||||||
|
await client.query('DELETE FROM staff_timesheet_entries WHERE tenant_id = $1', [tenantId]);
|
||||||
|
await client.query('DELETE FROM staff_employees WHERE tenant_id = $1', [tenantId]);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
|
||||||
actual_revision: 4,
|
|
||||||
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
|
||||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not write to staff_work_sessions, staff_timesheet_entries, or staff_sync_change_log on rejected shift commands', async () => {
|
||||||
|
const { database, queries } = createMockDb();
|
||||||
|
|
||||||
|
const service = new SyncService(database, systemActorId);
|
||||||
|
const commands: SyncCommand[] = [
|
||||||
|
{
|
||||||
|
command_type: 'upsert_work_session',
|
||||||
|
schema_version: 1,
|
||||||
|
idempotency_key: 'cmd-session-no-write',
|
||||||
|
correlation_id: newUlid(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
id: newUlid(),
|
||||||
|
employee_id: employeeId,
|
||||||
|
org_unit_id: newUlid(),
|
||||||
|
business_date: '2026-09-26',
|
||||||
|
status: 'confirmed',
|
||||||
|
worked_minutes: 2880,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command_type: 'upsert_timesheet_entry',
|
||||||
|
schema_version: 1,
|
||||||
|
idempotency_key: 'cmd-ts-no-write',
|
||||||
|
correlation_id: newUlid(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
id: newUlid(),
|
||||||
|
employee_id: employeeId,
|
||||||
|
work_date: '2026-09-26',
|
||||||
|
minutes_worked: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await service.push(tenantId, testDevice, commands);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results.every((r) => r.status === 'rejected')).toBe(true);
|
||||||
|
|
||||||
|
// Verify nothing is written to staff_work_sessions
|
||||||
|
expect(
|
||||||
|
queries.some(
|
||||||
|
(q) =>
|
||||||
|
q.sql.toLowerCase().includes('staff_work_sessions') &&
|
||||||
|
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// Verify nothing is written to staff_timesheet_entries
|
||||||
|
expect(
|
||||||
|
queries.some(
|
||||||
|
(q) =>
|
||||||
|
q.sql.toLowerCase().includes('staff_timesheet_entries') &&
|
||||||
|
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// Verify nothing is written to staff_sync_change_log
|
||||||
|
expect(
|
||||||
|
queries.some(
|
||||||
|
(q) =>
|
||||||
|
q.sql.toLowerCase().includes('staff_sync_change_log') &&
|
||||||
|
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// Verify commands ARE saved to inbox with status 'rejected'
|
||||||
|
const inboxInserts = queries.filter((q) =>
|
||||||
|
q.sql.includes('INSERT INTO staff_sync_command_inbox'),
|
||||||
|
);
|
||||||
|
expect(inboxInserts).toHaveLength(2);
|
||||||
|
for (const insert of inboxInserts) {
|
||||||
|
expect(insert.params).toContain('rejected');
|
||||||
|
expect(insert.params).toContain('COMMAND_NOT_SUPPORTED');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enforces batch size limits and payload size limits', async () => {
|
it('enforces batch size limits and payload size limits', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue