From 1df2019cb89cad69e2c86608dd3b43e18275e97c Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sat, 26 Sep 2026 14:31:45 +0700 Subject: [PATCH] fix(sync): reject work session and timesheet mutations from devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Предотвращение несанкционированного создания подтверждённых смен и начислений с устройств синхронизации. Команды upsert_work_session и upsert_timesheet_entry отклоняются сервером со статусом rejected и кодом COMMAND_NOT_SUPPORTED без записи в базу данных. Очистка интеграционного теста выполняется соединением мигратора в соответствии с ограничениями прав роли приложения. --- api/openapi.yaml | 40 ++- docs/OFFLINE-SYNC-PROTOCOL.md | 57 ++++- src/domain/sync/service.ts | 313 +++++++---------------- tests/integration/sync-rejection.test.ts | 208 +++++++++++++++ tests/unit/sync.test.ts | 151 +++++++---- 5 files changed, 479 insertions(+), 290 deletions(-) create mode 100644 tests/integration/sync-rejection.test.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index 6e768a0..5a0b88a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -691,7 +691,18 @@ paths: properties: device: 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: id: { $ref: '#/components/schemas/Ulid' } tenant_id: { $ref: '#/components/schemas/Ulid' } @@ -803,7 +814,8 @@ paths: type: array items: 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: cursor: { type: string } entity_type: { type: string } @@ -837,7 +849,15 @@ paths: maxItems: 100 items: 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: command_type: { type: string } schema_version: { type: integer } @@ -874,7 +894,16 @@ paths: conflict: oneOf: - 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: conflict_code: { type: string, enum: [REVISION_MISMATCH] } entity_type: { type: string } @@ -885,6 +914,9 @@ paths: client_action: { type: string, enum: [FETCH_LATEST_AND_RETRY] } - { type: 'null' } error_code: + description: > + Код ошибки, если status = rejected. + Команды `upsert_work_session` и `upsert_timesheet_entry` отклоняются с кодом COMMAND_NOT_SUPPORTED. oneOf: - { type: string } - { type: 'null' } diff --git a/docs/OFFLINE-SYNC-PROTOCOL.md b/docs/OFFLINE-SYNC-PROTOCOL.md index bbd474c..83f9368 100644 --- a/docs/OFFLINE-SYNC-PROTOCOL.md +++ b/docs/OFFLINE-SYNC-PROTOCOL.md @@ -3,6 +3,7 @@ ## 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. @@ -13,9 +14,11 @@ Krona Staff является модулем платформы Krona Business Pl ## 2. Жизненный цикл устройства и аутентификация ### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`) + Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`). **Запрос:** + ```json { "device_code": "TERM-MAIN-01", @@ -26,6 +29,7 @@ Krona Staff является модулем платформы Krona Business Pl ``` **Ответ (201 Created):** + ```json { "device": { @@ -48,11 +52,15 @@ Krona Staff является модулем платформы Krona Business Pl > Сырой токен `token` возвращается клиенту ровно один раз при регистрации. В базе данных (`staff_sync_devices`) сохраняется исключительно криптографический хэш SHA-256 (`token_hash`) и безопасная маска (`token_hint`). ### 2.2. Аутентификация запросов устройства + Каждый запрос от локального терминала к API синхронизации должен содержать заголовок: + ```http Authorization: Bearer ``` + Сервер: + 1. Вычисляет `SHA-256(device_token)`. 2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`. 3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`. @@ -61,9 +69,11 @@ Authorization: Bearer 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, @@ -77,28 +87,37 @@ Authorization: Bearer ## 3. Монотонный непрозрачный курсор изменений ### 3.1. Формат курсора + Курсор представляет собой непрозрачную строку вида: + ```text cur__ ``` + Например: `cur_105_7a9f8b2c1d`. + - ``: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`). - ``: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`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", @@ -106,7 +125,9 @@ cur__ "entity_types": ["employee", "work_session"] } ``` + Ответ: + ```json { "changes": [ @@ -133,12 +154,15 @@ cur__ ``` ### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`) + После успешного локального сохранения изменений устройство подтверждает обработанный курсор: + ```json { "cursor": "cur_106_3c4d5e6f7a" } ``` + Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений. --- @@ -146,13 +170,16 @@ cur__ ## 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": [ @@ -174,15 +201,23 @@ cur__ ``` ### 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). +### 4.3. Запрещённые команды + +Прямое создание или изменение смен (`upsert_work_session`) и табелей (`upsert_timesheet_entry`) с устройств строго запрещено для предотвращения несанкционированных начислений. При получении таких команд сервер отклоняет их со статусом `rejected` и кодом ошибки `COMMAND_NOT_SUPPORTED` (без записи в базу данных). + --- ## 5. Обнаружение конфликтов @@ -192,6 +227,7 @@ UNIQUE (tenant_id, device_id, idempotency_key) Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные. **Ответ с конфликтом (в составе `results`):** + ```json { "idempotency_key": "cmd-term01-778900", @@ -210,6 +246,7 @@ UNIQUE (tenant_id, device_id, idempotency_key) ``` Клиент, получив конфликт, обязан: + 1. Запросить актуальное состояние сущности через `POST /sync/pull`. 2. Обновить локальное представление. 3. При необходимости повторить действие с актуальной ревизией или уведомить оператора. @@ -220,15 +257,15 @@ UNIQUE (tenant_id, device_id, idempotency_key) Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков: -| Сущность | Разрешённые поля | Запрещённые данные | -| :--- | :--- | :--- | -| `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` | Сырые биометрические сэмплы сканера | +| Сущность | Разрешённые поля | Запрещённые данные | +| :----------------- | :-------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- | +| `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] > **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!** @@ -247,9 +284,11 @@ UNIQUE (tenant_id, device_id, idempotency_key) ## 8. Политика хранения данных (Retention) Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки: + ```sql SELECT * FROM staff_sync_cleanup_retention(30); ``` + - Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30). - Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней. - Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap). diff --git a/src/domain/sync/service.ts b/src/domain/sync/service.ts index ab4f20c..2eb9e5b 100644 --- a/src/domain/sync/service.ts +++ b/src/domain/sync/service.ts @@ -90,7 +90,11 @@ export class SyncService { const tokenTenant = SyncService.parseTenantFromToken(rawToken); if (providedTenantId && tokenTenant && providedTenantId !== tokenTenant) { - throw new SyncAuthError('Несоответствие арендатора в токене устройства', 'TENANT_MISMATCH', 403); + throw new SyncAuthError( + 'Несоответствие арендатора в токене устройства', + 'TENANT_MISMATCH', + 403, + ); } const tenantId = providedTenantId ?? tokenTenant; @@ -187,9 +191,10 @@ export class SyncService { const rawToken = `kstaff_dev_${tenantId}_${rawSecret}`; const tokenHash = SyncService.hashToken(rawToken); const tokenHint = rawToken.slice(0, 16); - const scopes = params.allowed_scopes && params.allowed_scopes.length > 0 - ? params.allowed_scopes - : ['attendance:sync']; + const scopes = + params.allowed_scopes && params.allowed_scopes.length > 0 + ? params.allowed_scopes + : ['attendance:sync']; const orgUnitId = params.org_unit_id ? parseUlid(params.org_unit_id) : null; @@ -210,7 +215,17 @@ export class SyncService { (id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by) 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`, - [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]!; @@ -326,7 +341,8 @@ export class SyncService { // 1. If cursor is provided -> incremental pull from staff_sync_change_log if (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<{ id: string; @@ -360,9 +376,8 @@ export class SyncService { created_at: r.created_at.toISOString(), })); - const nextCursor = changes.length > 0 - ? changes[changes.length - 1]!.cursor - : options.cursor; + const nextCursor = + changes.length > 0 ? changes[changes.length - 1]!.cursor : options.cursor; return { changes, @@ -380,16 +395,22 @@ export class SyncService { const latestCursor = encodeCursor(tenantId, maxSeq); 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[] = []; // Org Units if (wanted('org_unit')) { - const orgRes = await client.query<{ id: string; code: string; name: string; status: string }>( - `SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`, - [tenantId], - ); + const orgRes = await client.query<{ + id: string; + 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) { const payload: SafeOrgUnitPayload = { id: row.id, @@ -411,7 +432,12 @@ export class SyncService { // Professions 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`, [tenantId], ); @@ -662,7 +688,8 @@ export class SyncService { command_type: cmd.command_type, status: 'rejected', error_code: 'TIMESTAMP_OUT_OF_WINDOW', - error_message: 'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)', + error_message: + 'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)', }; await this.saveInboxCommand(client, tenantId, device.id, cmd, 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) VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9) 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 = { @@ -771,7 +808,15 @@ export class SyncService { 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 { idempotency_key: cmd.idempotency_key, @@ -781,207 +826,15 @@ export class SyncService { }; } - 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_work_session': case 'upsert_timesheet_entry': { - const entryId = (payload.id as string) || newUlid(); - const employeeId = payload.employee_id as string; - const workDate = payload.work_date as string; - const minutesWorked = typeof payload.minutes_worked === 'number' ? payload.minutes_worked : 0; - const tsStatus = (payload.status as string) || 'draft'; - const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1; - - if (!employeeId || !workDate) { - return { - idempotency_key: cmd.idempotency_key, - command_type: cmd.command_type, - status: 'rejected', - error_code: 'INVALID_TIMESHEET_PAYLOAD', - error_message: 'employee_id и work_date обязательны', - }; - } - - const existingRes = await client.query<{ id: string; revision: number; status: string }>( - `SELECT id, revision, status FROM staff_timesheet_entries WHERE tenant_id = $1 AND id = $2`, - [tenantId, entryId], - ); - - if (existingRes.rows.length > 0) { - const existing = existingRes.rows[0]!; - if (existing.revision !== clientRevision) { - const conflict: SyncConflict = { - conflict_code: 'REVISION_MISMATCH', - entity_type: 'timesheet_entry', - entity_id: entryId, - expected_revision: clientRevision, - actual_revision: existing.revision, - safe_description: 'Ревизия записи табеля на сервере изменилась', - client_action: 'FETCH_LATEST_AND_RETRY', - }; - return { - idempotency_key: cmd.idempotency_key, - command_type: cmd.command_type, - status: 'conflict', - conflict, - }; - } - - const nextRev = existing.revision + 1; - await client.query( - `UPDATE staff_timesheet_entries - SET minutes_worked = $1, status = $2, revision = $3, updated_at = now() - WHERE tenant_id = $4 AND id = $5`, - [minutesWorked, tsStatus, nextRev, tenantId, entryId], - ); - - const safePayload: SafeTimesheetEntryPayload = { - id: entryId, - employee_id: employeeId, - work_date: workDate, - minutes_worked: minutesWorked, - status: tsStatus, - revision: nextRev, - }; - - await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', nextRev, safePayload); - - return { - idempotency_key: cmd.idempotency_key, - command_type: cmd.command_type, - status: 'applied', - result: { id: entryId, revision: nextRev, status: 'applied' }, - }; - } - - const newRev = clientRevision; - await client.query( - `INSERT INTO staff_timesheet_entries - (id, tenant_id, employee_id, work_date, minutes_worked, status, revision, updated_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - [entryId, tenantId, employeeId, workDate, minutesWorked, tsStatus, newRev, device.id], - ); - - const safePayload: SafeTimesheetEntryPayload = { - id: entryId, - employee_id: employeeId, - work_date: workDate, - minutes_worked: minutesWorked, - status: tsStatus, - revision: newRev, - }; - - await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', newRev, safePayload); - return { idempotency_key: cmd.idempotency_key, command_type: cmd.command_type, - status: 'applied', - result: { id: entryId, revision: newRev, status: 'applied' }, + status: 'rejected', + error_code: 'COMMAND_NOT_SUPPORTED', + error_message: + 'Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено', }; } @@ -1052,17 +905,23 @@ export class SyncService { ); } - async cleanupRetention(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 }>( - `SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`, - [retentionDays], - ); - const row = res.rows[0]; - return { - purged_change_logs: Number(row?.purged_change_logs ?? 0), - purged_commands: Number(row?.purged_commands ?? 0), - }; - }); + async cleanupRetention( + 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 }>( + `SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`, + [retentionDays], + ); + const row = res.rows[0]; + return { + purged_change_logs: Number(row?.purged_change_logs ?? 0), + purged_commands: Number(row?.purged_commands ?? 0), + }; + }, + ); } } diff --git a/tests/integration/sync-rejection.test.ts b/tests/integration/sync-rejection.test.ts new file mode 100644 index 0000000..6f3ad3c --- /dev/null +++ b/tests/integration/sync-rejection.test.ts @@ -0,0 +1,208 @@ +import pg from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Database } from '../../src/infrastructure/database.js'; +import { newUlid, 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 migrationConnectionString = + process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL; +const describeDatabase = connectionString ? describe : describe.skip; + +const cleanupTables = [ + 'staff_sync_command_inbox', + 'staff_sync_change_log', + 'staff_sync_devices', + 'staff_work_sessions', + 'staff_timesheet_entries', + 'staff_employees', +] as const; + +describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => { + const database = new Database(connectionString!); + const tenantId = 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, + }; + + async function cleanupAsMigrator() { + if (!migrationConnectionString) { + return; + } + const migrator = new pg.Client({ + connectionString: migrationConnectionString, + application_name: 'krona-staff-test-cleanup', + }); + await migrator.connect(); + try { + await migrator.query('BEGIN'); + try { + await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]); + for (const table of cleanupTables) { + await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenantId]); + } + await migrator.query('COMMIT'); + } catch (error) { + await migrator.query('ROLLBACK'); + throw error; + } + } finally { + await migrator.end(); + } + } + + beforeAll(async () => { + await cleanupAsMigrator(); + await database.tenantTransaction(tenantId, actorId, async (client) => { + // Create employee + await client.query( + `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status, 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 () => { + try { + await cleanupAsMigrator(); + } finally { + await database.close(); + } + }); + + it('rejects upsert_work_session and creates NO records in staff_work_sessions or change log', async () => { + // S1 scenario: device attempts to create a confirmed work session with 2880 minutes + const command: SyncCommand = { + command_type: 'upsert_work_session', + schema_version: 1, + idempotency_key: 'cmd-live-s1-work-session', + correlation_id: newUlid(), + created_at: new Date().toISOString(), + payload: { + id: sessionId, + employee_id: employeeId, + business_date: '2026-09-26', + status: 'confirmed', + worked_minutes: 2880, + revision: 1, + }, + }; + + const results = await syncService.push(tenantId, testDevice, [command]); + expect(results).toHaveLength(1); + expect(results[0]?.status).toBe('rejected'); + expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED'); + expect(results[0]?.error_message).toContain('Команда не поддерживается'); + + // Verify 0 rows in staff_work_sessions + const sessionRes = await database.tenantTransaction(tenantId, actorId, (client) => + client.query<{ cnt: number }>( + 'SELECT count(*)::int as cnt FROM staff_work_sessions WHERE tenant_id = $1', + [tenantId], + ), + ); + expect(sessionRes.rows[0]?.cnt).toBe(0); + + // Verify 0 rows in staff_sync_change_log for work_session + const changeRes = await database.tenantTransaction(tenantId, actorId, (client) => + client.query<{ cnt: number }>( + "SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'work_session'", + [tenantId], + ), + ); + expect(changeRes.rows[0]?.cnt).toBe(0); + + // Verify recorded in staff_sync_command_inbox as rejected + const inboxRes = await database.tenantTransaction(tenantId, actorId, (client) => + client.query<{ status: string; error_code: string }>( + 'SELECT status, error_code FROM staff_sync_command_inbox WHERE tenant_id = $1 AND idempotency_key = $2', + [tenantId, command.idempotency_key], + ), + ); + expect(inboxRes.rows[0]?.status).toBe('rejected'); + expect(inboxRes.rows[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED'); + }); + + it('rejects upsert_timesheet_entry and creates NO records in staff_timesheet_entries or change log', async () => { + const command: SyncCommand = { + command_type: 'upsert_timesheet_entry', + schema_version: 1, + idempotency_key: 'cmd-live-timesheet-entry', + correlation_id: newUlid(), + created_at: new Date().toISOString(), + payload: { + id: timesheetId, + employee_id: employeeId, + work_date: '2026-09-26', + minutes_worked: 480, + status: 'approved', + revision: 1, + }, + }; + + const results = await syncService.push(tenantId, testDevice, [command]); + expect(results).toHaveLength(1); + expect(results[0]?.status).toBe('rejected'); + expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED'); + expect(results[0]?.error_message).toContain('Команда не поддерживается'); + + // Verify 0 rows in staff_timesheet_entries + const tsRes = await database.tenantTransaction(tenantId, actorId, (client) => + client.query<{ cnt: number }>( + 'SELECT count(*)::int as cnt FROM staff_timesheet_entries WHERE tenant_id = $1', + [tenantId], + ), + ); + expect(tsRes.rows[0]?.cnt).toBe(0); + + // Verify 0 rows in staff_sync_change_log for timesheet_entry + const changeRes = await database.tenantTransaction(tenantId, actorId, (client) => + client.query<{ cnt: number }>( + "SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'timesheet_entry'", + [tenantId], + ), + ); + expect(changeRes.rows[0]?.cnt).toBe(0); + }); +}); diff --git a/tests/unit/sync.test.ts b/tests/unit/sync.test.ts index 5f8826e..198fdb1 100644 --- a/tests/unit/sync.test.ts +++ b/tests/unit/sync.test.ts @@ -8,7 +8,9 @@ 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 }) { +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 }); @@ -105,7 +107,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => { 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')), + queries.some( + (q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at'), + ), ).toBe(true); }); @@ -187,7 +191,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => { }); 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', statusCode: 403, }); @@ -297,25 +303,15 @@ describe('Sync Push & Idempotency', () => { 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 { 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 { database } = createMockDb(); const service = new SyncService(database, systemActorId); const command: SyncCommand = { command_type: 'upsert_work_session', schema_version: 1, - idempotency_key: 'cmd-session-conflict', + idempotency_key: 'cmd-session-reject', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: { @@ -323,43 +319,28 @@ describe('Sync Push & Idempotency', () => { employee_id: employeeId, org_unit_id: newUlid(), business_date: '2026-09-26', - revision: 2, // Client expects revision 2, but server has 3 + revision: 1, worked_minutes: 480, + status: 'confirmed', }, }; 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', - }); + expect(results[0]?.status).toBe('rejected'); + expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED'); + expect(results[0]?.error_message).toContain('Команда не поддерживается'); }); - 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 { 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 { database } = createMockDb(); const service = new SyncService(database, systemActorId); const command: SyncCommand = { command_type: 'upsert_timesheet_entry', schema_version: 1, - idempotency_key: 'cmd-ts-conflict', + idempotency_key: 'cmd-ts-reject', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: { @@ -367,22 +348,92 @@ describe('Sync Push & Idempotency', () => { employee_id: employeeId, work_date: '2026-09-26', minutes_worked: 500, - revision: 1, // Mismatch: expects 1, server has 4 + revision: 1, }, }; 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', - }); + expect(results[0]?.status).toBe('rejected'); + expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED'); + expect(results[0]?.error_message).toContain('Команда не поддерживается'); + }); + + 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 () => {