Compare commits
2 commits
8e955b042b
...
b289dd6d63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b289dd6d63 | ||
|
|
fd1ec3d819 |
28 changed files with 833 additions and 523 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@
|
|||
В операционных системах Windows (начиная с Windows Vista / Server 2008 и включая Windows 10/11) службы выполняются в изолированной сессии **Session 0** без интерактивного рабочего стола.
|
||||
|
||||
### Результаты анализа ZKFinger SDK 5.3 и драйвера SLK20R в Session 0:
|
||||
|
||||
1. Пользовательская библиотека `libzkfp.dll` и драйвер `slk20r.sys` при инициализации используют оконные сообщения Win32 (`WM_DEVICECHANGE`) и оконные дескрипторы для отслеживания PnP-событий USB, а также дескрипторы интерфейсов устройств, привязанные к интерактивным пользовательским сессиям.
|
||||
2. При вызове `zkfp2.Init()` в Session 0 драйвер сообщает 0 подключённых устройств (`GetDeviceCount() == 0`) либо завершается с кодом ошибки дескриптора (-1 / -7).
|
||||
3. Попытка эксклюзивного захвата USB-устройства службой Session 0 блокирует доступ к сканеру со стороны интерактивного пользовательского сеанса для регистрации новых отпечатков.
|
||||
|
||||
### Принятое решение:
|
||||
|
||||
- **KronaStaffService** работает как системная служба Windows и берет на себя непрерывную синхронизацию очереди отметок, хранение состояния в `%ProgramData%\Krona\StaffTerminal` под защитой Windows DPAPI (`DataProtectionScope.LocalMachine`) и IPC-шлюз.
|
||||
- Интерактивное приложение **KronaStaffTerminal** подключается к сканеру SLK20R в пользовательской сессии (с автозапуском при входе в систему и сворачиванием в трей), выполняет высокоточное распознавание и регистрацию отпечатков, фиксирует отметки и передаёт их в очередь службы.
|
||||
- Если интерактивный пользователь выходит из системы или закрывает приложение, служба **KronaStaffService** продолжает выгрузку всех накопленных офлайн-событий на сервер.
|
||||
|
|
@ -27,12 +29,14 @@
|
|||
## Управление службой
|
||||
|
||||
Установка и регистрация службы:
|
||||
|
||||
```powershell
|
||||
sc.exe create KronaStaffService binPath= "C:\Program Files\Krona Staff Terminal\KronaStaffService.exe" start= auto obj= "NT AUTHORITY\SYSTEM" DisplayName= "Krona Staff Service"
|
||||
sc.exe start KronaStaffService
|
||||
```
|
||||
|
||||
Остановка и удаление:
|
||||
|
||||
```powershell
|
||||
sc.exe stop KronaStaffService
|
||||
sc.exe delete KronaStaffService
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ services:
|
|||
tmpfs: [/tmp]
|
||||
security_opt: ['no-new-privileges:true']
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --no-check-certificate -q -O - https://127.0.0.1:8080/api/v1/staff/live || exit 1']
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'wget --no-check-certificate -q -O - https://127.0.0.1:8080/api/v1/staff/live || exit 1',
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Document Version:** 1.0.0
|
||||
**Target Audience:** Compliance Officers, Legal Counsel, System Administrators, DevOps
|
||||
**Affected Hardware:** ZKTeco SLK20R, BioLink U-Match, ZK6500, ZK8500, ZK9500
|
||||
**Affected Hardware:** ZKTeco SLK20R, BioLink U-Match, ZK6500, ZK8500, ZK9500
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,9 +21,10 @@ The device driver must be obtained by end-users or enterprise system administrat
|
|||
The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`apps/KronaStaffTerminal/Vendor/ZKFinger/LICENSE.txt`) contains the following clause in **Section 2 (Description of Other Rights and Limitations)**:
|
||||
|
||||
> **Distribution.**
|
||||
> *"The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products."*
|
||||
> _"The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products."_
|
||||
|
||||
### Legal Interpretation:
|
||||
|
||||
1. **Commercial Benefit Restriction:** Krona Staff is a commercial workforce management system provided under commercial software licensing terms. Bundling the ZKTeco drivers or redistributable setup packages directly inside the commercial installer (`KronaStaffTerminal-Setup-*.exe`) could be construed as including the software product in a package intended to derive commercial benefit, violating Section 2.
|
||||
2. **Permitted Managed Integration:** Building client applications that link against the public SDK wrapper library while requiring users to independently install the vendor-supplied hardware driver for their purchased optical scanner is standard industry practice and fully respects the hardware vendor's EULA.
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`
|
|||
To ensure a seamless operational experience without violating vendor licensing terms, the Krona Staff installer and application implement non-blocking automated driver detection and guidance:
|
||||
|
||||
### 3.1 Installer Detection Flow
|
||||
|
||||
1. During installation (via `installer/KronaStaffTerminal.nsi`), the installer checks for the presence of the SLK20R / ZKFinger driver across standard system locations:
|
||||
- System driver directory: `%WINDIR%\System32\drivers\slk20r.sys`, `zkfp.sys`, `zkfpos.sys`, `ZKUSB.sys`
|
||||
- Windows Service Registry keys:
|
||||
|
|
@ -47,9 +49,10 @@ To ensure a seamless operational experience without violating vendor licensing t
|
|||
- The installer does **not** fail or block installation; it completes cleanly so that the terminal and synchronization service are ready once the driver is installed.
|
||||
|
||||
### 3.2 Terminal Runtime Flow
|
||||
|
||||
1. If the terminal starts without the driver installed:
|
||||
- The scanner state transitions to `ScannerState.DriverError` or `ScannerState.Disconnected`.
|
||||
- The system tray icon updates to `TrayState.NoScanner` (amber warning icon) with tooltip: *"Сканер отпечатков пальцев не подключен или не установлен драйвер"*.
|
||||
- The system tray icon updates to `TrayState.NoScanner` (amber warning icon) with tooltip: _"Сканер отпечатков пальцев не подключен или не установлен драйвер"_.
|
||||
- The main UI displays an alert InfoBar with instructions and a link to the official ZKTeco driver download page.
|
||||
- The terminal remains usable: administrator PIN authentication, employee listing, offline queue synchronization, and manual attendance tracking continue to operate normally.
|
||||
2. When the administrator plugs in the scanner and installs the driver, the terminal's background auto-reconnect loop automatically detects the device within 2 seconds without requiring an application restart.
|
||||
|
|
@ -59,6 +62,7 @@ To ensure a seamless operational experience without violating vendor licensing t
|
|||
## 4. Enterprise Mass Deployment Guide
|
||||
|
||||
For automated deployment via Microsoft Intune, Group Policy (GPO), or SCCM:
|
||||
|
||||
1. Download the official ZKTeco SLK20R Driver package (`ZKFinger SDK / Driver Setup`).
|
||||
2. Extract the driver INF and SYS files (`slk20r.inf`, `slk20r.sys`, `slk20rx64.cat`).
|
||||
3. Deploy the driver package silently using standard Windows DISM or pnputil:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
**Test Date:** September 2026
|
||||
**Hardware Tested:** ZKTeco SLK20R Optical USB Fingerprint Reader
|
||||
**Driver:** ZKTeco SLK20R USB Driver (slk20r.sys v2.1.0) / ZKFinger SDK 5.3
|
||||
**Operating Systems:** Windows 10 Pro (22H2 x64), Windows 11 Enterprise (24H2 x64), Windows Server 2022 (Session 0)
|
||||
**Operating Systems:** Windows 10 Pro (22H2 x64), Windows 11 Enterprise (24H2 x64), Windows Server 2022 (Session 0)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
This report documents empirical hardware testing and architectural findings gathered during the integration of the ZKTeco SLK20R fingerprint scanner into the Krona Staff Terminal ecosystem.
|
||||
|
||||
### Key Takeaways:
|
||||
|
||||
1. **Session 0 Limitation:** The ZKTeco USB driver and native SDK (`libzkfp.dll`) rely on interactive desktop Windows messaging subsystems (`WM_DEVICECHANGE`) and Win32 user session interfaces. Running biometric capture inside a Windows Service (Session 0) consistently enumerates `0` devices.
|
||||
2. **Hybrid Solution Adopted:** A decoupled dual-process architecture:
|
||||
- **`KronaStaffTerminal.exe` (Interactive User Session):** Owns hardware USB scanner lifecycle, capture loop, biometric template matching, and multi-finger enrollment.
|
||||
|
|
@ -25,18 +26,23 @@ This report documents empirical hardware testing and architectural findings gath
|
|||
## 2. Session 0 Investigation & Architectural Analysis
|
||||
|
||||
### The Problem
|
||||
|
||||
During development of the headless Windows Service (`KronaStaffService`), tests were performed to verify whether `libzkfp.dll` could capture fingerprints in Session 0 (running as `NT AUTHORITY\SYSTEM`).
|
||||
|
||||
#### Empirical Observations:
|
||||
|
||||
- In Windows Service Session 0, calling `zkfp2.Init()` returns `0` (ZKFP_ERR_OK).
|
||||
- Calling `zkfp2.GetDeviceCount()` immediately following returns `0`, despite the USB scanner being plugged into the machine and operational in Device Manager.
|
||||
- Attempting to pass handle `0` to `zkfp2.OpenDevice(0)` returns error code `-1` (device not found).
|
||||
|
||||
#### Root Cause:
|
||||
|
||||
The ZKTeco SLK20R driver implementation (`slk20r.sys`) registers device interfaces that bind specifically to interactive Windows desktop sessions. Because Session 0 in modern Windows (Vista through Windows 11/Server 2022) is isolated from window stations, the native driver cannot establish the USB pipe without an interactive user station (`WinSta0`).
|
||||
|
||||
#### Architectural Resolution:
|
||||
|
||||
Instead of forcing unreliable Session 0 USB hooks or interactive service workarounds (which violate Microsoft Windows Security Guidelines), the architecture cleanly divides responsibilities:
|
||||
|
||||
```
|
||||
+--------------------------------------------------------------+
|
||||
| Interactive User Session |
|
||||
|
|
@ -69,14 +75,18 @@ Instead of forcing unreliable Session 0 USB hooks or interactive service workaro
|
|||
## 3. SDK Device Deduplication Analysis
|
||||
|
||||
### The Problem
|
||||
|
||||
Under certain driver configurations and USB 3.0/3.1 xHCI host controllers, `zkfp2.GetDeviceCount()` reports `2` or more devices even when only a single physical SLK20R reader is connected.
|
||||
|
||||
Opening device index `1` resulted in one of two failure modes:
|
||||
|
||||
1. `OpenDevice(1)` returned error `-2` (device already open/busy).
|
||||
2. `OpenDevice(1)` succeeded as a phantom clone that interfered with image acquisition on device index `0`, causing dropped frames and degraded capture reliability.
|
||||
|
||||
### The Solution: Serial Number Deduplication
|
||||
|
||||
`FingerprintScanner.cs` inspects hardware parameters for each enumerated slot:
|
||||
|
||||
```csharp
|
||||
// Query serial number parameter (parameter codes 1101, 1102, 106)
|
||||
byte[] serialBuffer = new byte[256];
|
||||
|
|
@ -87,6 +97,7 @@ if (zkfp2.GetParameters(devHandle, 1101, serialBuffer, ref size) == 0 && size >
|
|||
serial = Encoding.UTF8.GetString(serialBuffer, 0, size).Trim('\0', ' ', '\r', '\n');
|
||||
}
|
||||
```
|
||||
|
||||
If an enumerated slot returns a serial number that has already been registered by slot `0`, the terminal skips the duplicate slot and logs a diagnostic warning. This guarantees that only one active capture session exists per physical unit.
|
||||
|
||||
---
|
||||
|
|
@ -94,11 +105,13 @@ If an enumerated slot returns a serial number that has already been registered b
|
|||
## 4. USB Disconnection & Hotplug Recovery Testing
|
||||
|
||||
### Test Scenario
|
||||
|
||||
1. Terminal running in kiosk mode with SLK20R connected.
|
||||
2. Employee clocks in successfully.
|
||||
3. USB cable is physically unplugged or hub power is cycled.
|
||||
|
||||
### Observed Results
|
||||
|
||||
- The active capture thread in `zkfp2.AcquireFingerprint` returns negative error code `-101` or `-102` (ZKFP_ERR_IO / disconnect).
|
||||
- Without reconnect logic, earlier versions hung in a tight error loop, pinning the CPU at 100%.
|
||||
- **With Production Reconnect Logic:**
|
||||
|
|
@ -115,12 +128,12 @@ If an enumerated slot returns a serial number that has already been registered b
|
|||
|
||||
## 5. Performance & Biometric Accuracy Verification
|
||||
|
||||
| Metric | Target | Measured Result | Status |
|
||||
|---|---|---|---|
|
||||
| Finger placement to verification | < 1000 ms | ~420 ms | PASSED |
|
||||
| 1:N Biometric Matching (100 templates) | < 100 ms | 14 ms | PASSED |
|
||||
| False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED |
|
||||
| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED |
|
||||
| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED |
|
||||
| Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED |
|
||||
| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING |
|
||||
| Metric | Target | Measured Result | Status |
|
||||
| ------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Finger placement to verification | < 1000 ms | ~420 ms | PASSED |
|
||||
| 1:N Biometric Matching (100 templates) | < 100 ms | 14 ms | PASSED |
|
||||
| False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED |
|
||||
| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED |
|
||||
| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED |
|
||||
| Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED |
|
||||
| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING |
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## 1. Архитектурный контекст и инварианты
|
||||
|
||||
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 <device_token>
|
||||
```
|
||||
|
||||
Сервер:
|
||||
|
||||
1. Вычисляет `SHA-256(device_token)`.
|
||||
2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`.
|
||||
3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`.
|
||||
|
|
@ -61,9 +69,11 @@ Authorization: Bearer <device_token>
|
|||
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 <device_token>
|
|||
## 3. Монотонный непрозрачный курсор изменений
|
||||
|
||||
### 3.1. Формат курсора
|
||||
|
||||
Курсор представляет собой непрозрачную строку вида:
|
||||
|
||||
```text
|
||||
cur_<sequence>_<signature_hash>
|
||||
```
|
||||
|
||||
Например: `cur_105_7a9f8b2c1d`.
|
||||
|
||||
- `<sequence>`: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`).
|
||||
- `<signature_hash>`: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`SHA-256(tenant_id + seq + salt)`).
|
||||
|
||||
Свойства курсора:
|
||||
|
||||
- **Монотонность**: Гарантирует строго возрастающий порядок чтения журнала изменений.
|
||||
- **Независимость от времени**: Не зависит от системных часов терминала и защищён от сдвигов локального времени.
|
||||
- **Целостность и изоляция**: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой `INVALID_CURSOR` (400 Bad Request).
|
||||
|
||||
### 3.2. Начальная загрузка (Bootstrap, `cursor = null`)
|
||||
|
||||
Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет `POST /api/v1/staff/sync/pull` с пустым или отсутствующим курсором (`cursor: null`):
|
||||
|
||||
1. Сервер формирует снимок текущих **актуальных** данных для поддерживаемых сущностей (`org_unit`, `profession`, `employee`, `work_session`, `timesheet_entry`, `planned_shift`).
|
||||
2. Для каждой сущности формируется безопасная полезная нагрузка с операцией `upsert`.
|
||||
3. В ответе возвращается `next_cursor`, соответствующий текущему максимальному `cursor_seq` журнала изменений сервера.
|
||||
4. После применения начального снимка терминал фиксирует `next_cursor` и переходит в режим инкрементального получения дельт.
|
||||
|
||||
### 3.3. Инкрементальная загрузка (Pull, `POST /api/v1/staff/sync/pull`)
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"cursor": "cur_105_7a9f8b2c1d",
|
||||
|
|
@ -106,7 +125,9 @@ cur_<sequence>_<signature_hash>
|
|||
"entity_types": ["employee", "work_session"]
|
||||
}
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"changes": [
|
||||
|
|
@ -133,12 +154,15 @@ cur_<sequence>_<signature_hash>
|
|||
```
|
||||
|
||||
### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`)
|
||||
|
||||
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
|
||||
|
||||
```json
|
||||
{
|
||||
"cursor": "cur_106_3c4d5e6f7a"
|
||||
}
|
||||
```
|
||||
|
||||
Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
|
||||
|
||||
---
|
||||
|
|
@ -146,13 +170,16 @@ cur_<sequence>_<signature_hash>
|
|||
## 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,11 +201,15 @@ cur_<sequence>_<signature_hash>
|
|||
```
|
||||
|
||||
### 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).
|
||||
|
|
@ -192,6 +223,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
|||
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
|
||||
|
||||
**Ответ с конфликтом (в составе `results`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"idempotency_key": "cmd-term01-778900",
|
||||
|
|
@ -210,6 +242,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
|
|||
```
|
||||
|
||||
Клиент, получив конфликт, обязан:
|
||||
|
||||
1. Запросить актуальное состояние сущности через `POST /sync/pull`.
|
||||
2. Обновить локальное представление.
|
||||
3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
|
||||
|
|
@ -220,15 +253,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 +280,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).
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Document Version:** 1.0.0
|
||||
**Status:** Approved for Production
|
||||
**Target Component:** Krona Staff Backend & Krona Staff Terminal / Windows Service Sync Client
|
||||
**Target Component:** Krona Staff Backend & Krona Staff Terminal / Windows Service Sync Client
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Service (`KronaStaffService.exe`) operate on Windows POS / kiosk devices in branch locations. The terminal functions both in fully connected environments and during extended offline network outages.
|
||||
|
||||
### Security Guarantees
|
||||
|
||||
1. **Biometric Isolation:** Biometric fingerprint templates **NEVER** leave the physical terminal. Templates are stored strictly in local DPAPI-encrypted machine storage (`%ProgramData%\Krona\StaffTerminal\terminal-state.dat`) and are matched on-device using the biometric matching engine. No raw images, templates, minutiae, or biometric features are ever transmitted to the server or written to network logs.
|
||||
2. **TLS Mandate:** All communications with the Krona Staff backend **MUST** occur over TLS 1.3 or TLS 1.2 with strict certificate verification. Disabling certificate validation or bypassing SNI/SAN checks is strictly forbidden.
|
||||
3. **Transport Protocol:** Synchronous HTTPS POST requests over standard REST endpoints.
|
||||
|
|
@ -20,18 +21,20 @@ The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Se
|
|||
## 2. Attendance Sync Endpoint Specification
|
||||
|
||||
### Endpoint
|
||||
|
||||
```http
|
||||
POST /api/v1/staff/attendance/events
|
||||
```
|
||||
|
||||
### Request Headers
|
||||
| Header Name | Type | Required | Description | Example |
|
||||
|---|---|---|---|---|
|
||||
| `Content-Type` | String | Yes | Media type of payload | `application/json` |
|
||||
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
|
||||
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
|
||||
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
|
||||
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
|
||||
|
||||
| Header Name | Type | Required | Description | Example |
|
||||
| ----------------- | ------ | -------- | ------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `Content-Type` | String | Yes | Media type of payload | `application/json` |
|
||||
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
|
||||
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
|
||||
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
|
||||
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -40,6 +43,7 @@ POST /api/v1/staff/attendance/events
|
|||
The synchronization client supports sending both individual attendance records and batched events.
|
||||
|
||||
### 3.1 Single Event Record Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G",
|
||||
|
|
@ -54,7 +58,9 @@ The synchronization client supports sending both individual attendance records a
|
|||
```
|
||||
|
||||
### 3.2 Batch Sync Payload
|
||||
|
||||
When draining the offline sync queue after network recovery, the client posts an array of queued events:
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceId": "TERM-SPB-OFFICE-01",
|
||||
|
|
@ -83,6 +89,7 @@ When draining the offline sync queue after network recovery, the client posts an
|
|||
```
|
||||
|
||||
### Field Definitions
|
||||
|
||||
- **`eventId`** (`string`, ULID or UUIDv4): Client-generated immutable identifier of the attendance event.
|
||||
- **`employeeId`** (`string`, UUIDv4): Server-recognized unique ID of the employee.
|
||||
- **`eventType`** (`string`): Enum representing the punch action:
|
||||
|
|
@ -103,15 +110,15 @@ When draining the offline sync queue after network recovery, the client posts an
|
|||
|
||||
## 4. HTTP Response Codes & Client Handling
|
||||
|
||||
| HTTP Status | Meaning | Terminal Client Action |
|
||||
|---|---|---|
|
||||
| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. |
|
||||
| `409 Conflict` | Event with `eventId` or `Idempotency-Key` was already committed by server | **Treated as idempotent success.** Dequeue event immediately from local store to prevent head-of-line blocking. |
|
||||
| `400 Bad Request` | Malformed payload schema | Log diagnostic event in crash log; quarantine event into dead-letter store to prevent blocking subsequent events. |
|
||||
| `401 Unauthorized` / `403 Forbidden` | Invalid API token or revoked terminal license | Retain events in offline queue; update system tray status to `TrayState.Error` (`401 Auth Error`); prompt administrator. |
|
||||
| `429 Too Many Requests` | Server rate limit reached | Back off exponentially, respecting `Retry-After` header if present. |
|
||||
| `500` / `502` / `503` / `504` | Temporary server-side failure / Gateway error | Retain events in queue; schedule retry with exponential backoff (initial delay 5s, max delay 300s, with randomized jitter). |
|
||||
| Network Timeout / Socket Error | Terminal offline or disconnected | Keep events in persistent DPAPI-protected queue; retry on timer or upon Windows network change notification (`NetworkChange.NetworkAddressChanged`). |
|
||||
| HTTP Status | Meaning | Terminal Client Action |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. |
|
||||
| `409 Conflict` | Event with `eventId` or `Idempotency-Key` was already committed by server | **Treated as idempotent success.** Dequeue event immediately from local store to prevent head-of-line blocking. |
|
||||
| `400 Bad Request` | Malformed payload schema | Log diagnostic event in crash log; quarantine event into dead-letter store to prevent blocking subsequent events. |
|
||||
| `401 Unauthorized` / `403 Forbidden` | Invalid API token or revoked terminal license | Retain events in offline queue; update system tray status to `TrayState.Error` (`401 Auth Error`); prompt administrator. |
|
||||
| `429 Too Many Requests` | Server rate limit reached | Back off exponentially, respecting `Retry-After` header if present. |
|
||||
| `500` / `502` / `503` / `504` | Temporary server-side failure / Gateway error | Retain events in queue; schedule retry with exponential backoff (initial delay 5s, max delay 300s, with randomized jitter). |
|
||||
| Network Timeout / Socket Error | Terminal offline or disconnected | Keep events in persistent DPAPI-protected queue; retry on timer or upon Windows network change notification (`NetworkChange.NetworkAddressChanged`). |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ const currentDefaultPrivileges = await readFile(
|
|||
'utf8',
|
||||
);
|
||||
if (/ALTER DEFAULT PRIVILEGES[\s\S]*?\bGRANT\b/i.test(currentDefaultPrivileges)) {
|
||||
throw new Error('Runtime roles must receive explicit object grants; broad default grants are forbidden');
|
||||
throw new Error(
|
||||
'Runtime roles must receive explicit object grants; broad default grants are forbidden',
|
||||
);
|
||||
}
|
||||
console.log(`validated ${ups.length} reversible migration(s)`);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ function secureTokenEquals(candidate: string | undefined, expected: string): boo
|
|||
if (!candidate) return false;
|
||||
const candidateBuffer = Buffer.from(candidate, 'utf8');
|
||||
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
|
||||
return (
|
||||
candidateBuffer.length === expectedBuffer.length &&
|
||||
timingSafeEqual(candidateBuffer, expectedBuffer)
|
||||
);
|
||||
}
|
||||
|
||||
export class PlatformIdentityAdapter implements IdentityPort {
|
||||
|
|
|
|||
35
src/app.ts
35
src/app.ts
|
|
@ -136,14 +136,33 @@ export async function buildApp({
|
|||
{ prefix: apiPrefix },
|
||||
);
|
||||
app.get('/', async (_request, reply) => reply.redirect('/staff'));
|
||||
app.get('/staff', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml));
|
||||
app.get('/staff/styles.css', async (_request, reply) => reply.type('text/css; charset=utf-8').send(portalCss));
|
||||
app.get('/staff/app.js', async (_request, reply) => reply.type('application/javascript; charset=utf-8').send(portalJs));
|
||||
app.get('/staff/sw.js', async (_request, reply) => reply.header('service-worker-allowed', '/staff').type('application/javascript; charset=utf-8').send(portalServiceWorker));
|
||||
app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest));
|
||||
app.get('/staff/icon-192.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon192));
|
||||
app.get('/staff/icon-512.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon512));
|
||||
app.get('/staff/*', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml));
|
||||
app.get('/staff', async (_request, reply) =>
|
||||
reply.type('text/html; charset=utf-8').send(portalHtml),
|
||||
);
|
||||
app.get('/staff/styles.css', async (_request, reply) =>
|
||||
reply.type('text/css; charset=utf-8').send(portalCss),
|
||||
);
|
||||
app.get('/staff/app.js', async (_request, reply) =>
|
||||
reply.type('application/javascript; charset=utf-8').send(portalJs),
|
||||
);
|
||||
app.get('/staff/sw.js', async (_request, reply) =>
|
||||
reply
|
||||
.header('service-worker-allowed', '/staff')
|
||||
.type('application/javascript; charset=utf-8')
|
||||
.send(portalServiceWorker),
|
||||
);
|
||||
app.get('/staff/manifest.webmanifest', async (_request, reply) =>
|
||||
reply.type('application/manifest+json').send(portalManifest),
|
||||
);
|
||||
app.get('/staff/icon-192.svg', async (_request, reply) =>
|
||||
reply.type('image/svg+xml').send(portalIcon192),
|
||||
);
|
||||
app.get('/staff/icon-512.svg', async (_request, reply) =>
|
||||
reply.type('image/svg+xml').send(portalIcon512),
|
||||
);
|
||||
app.get('/staff/*', async (_request, reply) =>
|
||||
reply.type('text/html; charset=utf-8').send(portalHtml),
|
||||
);
|
||||
|
||||
app.addHook('onClose', async () => database.close());
|
||||
return app;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ const configSchema = z.object({
|
|||
STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
||||
STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
||||
STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined(
|
||||
z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/).optional(),
|
||||
z
|
||||
.string()
|
||||
.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/)
|
||||
.optional(),
|
||||
),
|
||||
HTTPS_PFX_PATH: emptyStringAsUndefined(z.string().optional()),
|
||||
HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()),
|
||||
|
|
|
|||
45
src/core/outbox.ts
Normal file
45
src/core/outbox.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { PoolClient } from 'pg';
|
||||
import { newUlid, type Ulid } from './ids.js';
|
||||
|
||||
export interface OutboxEventInput<TPayload extends Record<string, unknown>> {
|
||||
tenantId: Ulid;
|
||||
eventType: string;
|
||||
subjectType: string;
|
||||
subjectId: Ulid;
|
||||
actorType: 'user' | 'service';
|
||||
actorId: Ulid;
|
||||
payload: TPayload;
|
||||
correlationId: Ulid;
|
||||
causationId?: Ulid;
|
||||
occurredAt?: string;
|
||||
eventVersion?: number;
|
||||
}
|
||||
|
||||
export async function insertOutboxEvent<TPayload extends Record<string, unknown>>(
|
||||
client: Pick<PoolClient, 'query'>,
|
||||
event: OutboxEventInput<TPayload>,
|
||||
): Promise<Ulid> {
|
||||
const id = newUlid();
|
||||
const occurredAt = event.occurredAt ?? new Date().toISOString();
|
||||
await client.query(
|
||||
`INSERT INTO staff_outbox
|
||||
(id, tenant_id, event_type, event_version, subject_type, subject_id,
|
||||
actor_type, actor_id, payload, correlation_id, causation_id, occurred_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)`,
|
||||
[
|
||||
id,
|
||||
event.tenantId,
|
||||
event.eventType,
|
||||
event.eventVersion ?? 1,
|
||||
event.subjectType,
|
||||
event.subjectId,
|
||||
event.actorType,
|
||||
event.actorId,
|
||||
JSON.stringify(event.payload),
|
||||
event.correlationId,
|
||||
event.causationId ?? null,
|
||||
occurredAt,
|
||||
],
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
|
@ -7,10 +7,7 @@ export interface AttendanceEventInput {
|
|||
}
|
||||
|
||||
export type WorkSessionAnomaly =
|
||||
| 'auto_closed'
|
||||
| 'missing_check_in'
|
||||
| 'missing_check_out'
|
||||
| 'repeated_check_in';
|
||||
'auto_closed' | 'missing_check_in' | 'missing_check_out' | 'repeated_check_in';
|
||||
|
||||
export interface ReconcileWorkSessionOptions {
|
||||
now: string;
|
||||
|
|
@ -37,7 +34,10 @@ export function reconcileWorkSession(
|
|||
events: readonly AttendanceEventInput[],
|
||||
options: ReconcileWorkSessionOptions,
|
||||
): WorkSessionCandidate | null {
|
||||
if (!Number.isInteger(options.openShiftThresholdMinutes) || options.openShiftThresholdMinutes < 1) {
|
||||
if (
|
||||
!Number.isInteger(options.openShiftThresholdMinutes) ||
|
||||
options.openShiftThresholdMinutes < 1
|
||||
) {
|
||||
throw new Error('openShiftThresholdMinutes must be a positive integer');
|
||||
}
|
||||
if (events.length === 0) return null;
|
||||
|
|
@ -46,8 +46,9 @@ export function reconcileWorkSession(
|
|||
const automaticCloseAt = options.automaticCloseAt
|
||||
? parseInstant(options.automaticCloseAt, 'automaticCloseAt')
|
||||
: undefined;
|
||||
const parsed = events.map(parseEvent).sort((left, right) =>
|
||||
left.timestamp - right.timestamp || left.id.localeCompare(right.id));
|
||||
const parsed = events
|
||||
.map(parseEvent)
|
||||
.sort((left, right) => left.timestamp - right.timestamp || left.id.localeCompare(right.id));
|
||||
|
||||
const anomalies = new Set<WorkSessionAnomaly>();
|
||||
const attendanceEventIds: string[] = [];
|
||||
|
|
@ -79,9 +80,8 @@ export function reconcileWorkSession(
|
|||
}
|
||||
|
||||
if (openAt !== null) {
|
||||
const canAutoClose = automaticCloseAt !== undefined
|
||||
&& automaticCloseAt > openAt
|
||||
&& automaticCloseAt <= now;
|
||||
const canAutoClose =
|
||||
automaticCloseAt !== undefined && automaticCloseAt > openAt && automaticCloseAt <= now;
|
||||
if (canAutoClose) {
|
||||
workedMilliseconds += automaticCloseAt - openAt;
|
||||
lastEnd = automaticCloseAt;
|
||||
|
|
|
|||
|
|
@ -149,10 +149,7 @@ export function createVapidAuthorizationHeader(
|
|||
/**
|
||||
* Encrypt a payload using AES-128-GCM content encoding (RFC 8291 / RFC 8188).
|
||||
*/
|
||||
export function encryptPushPayload(
|
||||
payload: string | Buffer,
|
||||
keys: PushSubscriptionKeys,
|
||||
): Buffer {
|
||||
export function encryptPushPayload(payload: string | Buffer, keys: PushSubscriptionKeys): Buffer {
|
||||
const plaintext = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
|
||||
const recipientP256dh = Buffer.from(keys.p256dh, 'base64url');
|
||||
const recipientAuth = Buffer.from(keys.auth, 'base64url');
|
||||
|
|
@ -176,9 +173,7 @@ export function encryptPushPayload(
|
|||
recipientP256dh,
|
||||
senderPublicKey,
|
||||
]);
|
||||
const prk = Buffer.from(
|
||||
crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32),
|
||||
);
|
||||
const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32));
|
||||
|
||||
// 3. Generate 16-byte random salt
|
||||
const salt = crypto.randomBytes(16);
|
||||
|
|
@ -235,11 +230,11 @@ export async function sendWebPush(
|
|||
const response = await fetchImpl(target.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authHeader,
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'TTL': String(options?.ttl ?? 86400),
|
||||
'Urgency': options?.urgency ?? 'normal',
|
||||
TTL: String(options?.ttl ?? 86400),
|
||||
Urgency: options?.urgency ?? 'normal',
|
||||
},
|
||||
body: new Uint8Array(encryptedBody),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { PoolClient } from 'pg';
|
||||
import type { RequestContext } from '../../core/context.js';
|
||||
import { newUlid, parseUlid } from '../../core/ids.js';
|
||||
import { insertOutboxEvent } from '../../core/outbox.js';
|
||||
import { calculateDay, type DayParticipant, type PayrollLine } from './calculator.js';
|
||||
import { cateringPreset } from './presets.js';
|
||||
import type { RuleNode } from './rule.js';
|
||||
|
|
@ -767,21 +768,20 @@ export async function calculatePayrollPeriod(
|
|||
[tenantId, runId, totalAccruedMinor.toString(), calculatedAt],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO staff_outbox
|
||||
(id, tenant_id, event_type, event_version, subject_type, subject_id,
|
||||
actor_type, actor_id, payload, correlation_id)
|
||||
VALUES ($1, $2, 'staff.payroll.run_calculated', 1, 'PayrollRun', $3,
|
||||
'user', $4, $5, $6)`,
|
||||
[
|
||||
newUlid(),
|
||||
tenantId,
|
||||
runId,
|
||||
actorId,
|
||||
{ period_start: from, period_end: to, total_accrued_minor: totalAccruedMinor.toString() },
|
||||
correlationId,
|
||||
],
|
||||
);
|
||||
await insertOutboxEvent(client, {
|
||||
tenantId,
|
||||
eventType: 'staff.payroll.run_calculated',
|
||||
subjectType: 'PayrollRun',
|
||||
subjectId: parseUlid(runId),
|
||||
actorType: 'user',
|
||||
actorId,
|
||||
payload: {
|
||||
period_start: from,
|
||||
period_end: to,
|
||||
total_accrued_minor: totalAccruedMinor.toString(),
|
||||
},
|
||||
correlationId,
|
||||
});
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO staff_audit_log
|
||||
|
|
@ -914,25 +914,20 @@ export async function approvePayrollRun(
|
|||
[tenantId, runId, actorId, approvedAt],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO staff_outbox
|
||||
(id, tenant_id, event_type, event_version, subject_type, subject_id,
|
||||
actor_type, actor_id, payload, correlation_id)
|
||||
VALUES ($1, $2, 'staff.payroll.run_approved', 1, 'PayrollRun', $3,
|
||||
'user', $4, $5, $6)`,
|
||||
[
|
||||
newUlid(),
|
||||
tenantId,
|
||||
runId,
|
||||
actorId,
|
||||
{
|
||||
period_start: run.period_start,
|
||||
period_end: run.period_end,
|
||||
total_accrued_minor: run.total_accrued_minor,
|
||||
},
|
||||
correlationId,
|
||||
],
|
||||
);
|
||||
await insertOutboxEvent(client, {
|
||||
tenantId,
|
||||
eventType: 'staff.payroll.run_approved',
|
||||
subjectType: 'PayrollRun',
|
||||
subjectId: parseUlid(runId),
|
||||
actorType: 'user',
|
||||
actorId,
|
||||
payload: {
|
||||
period_start: run.period_start,
|
||||
period_end: run.period_end,
|
||||
total_accrued_minor: run.total_accrued_minor,
|
||||
},
|
||||
correlationId,
|
||||
});
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO staff_audit_log
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ export function decodeCursor(tenantId: string, cursor: string): bigint {
|
|||
candidateBuffer.length !== expectedBuffer.length ||
|
||||
!crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
|
||||
) {
|
||||
throw new InvalidCursorError('Подпись курсора синхронизации недействительна для данного арендатора');
|
||||
throw new InvalidCursorError(
|
||||
'Подпись курсора синхронизации недействительна для данного арендатора',
|
||||
);
|
||||
}
|
||||
|
||||
return BigInt(seqStr);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|||
import type { AppConfig } from './config.js';
|
||||
import type { RequestContext } from './core/context.js';
|
||||
import { newUlid, parseUlid } from './core/ids.js';
|
||||
import { insertOutboxEvent } from './core/outbox.js';
|
||||
import type { IdentityPort } from './core/ports.js';
|
||||
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
|
||||
import type { Database } from './infrastructure/database.js';
|
||||
|
|
@ -176,16 +177,12 @@ export async function registerPortalRoutes(
|
|||
return reply.notFound('Сотрудник для текущего пользователя не найден.');
|
||||
}
|
||||
|
||||
const saved = await pushDispatcher.saveSubscription(
|
||||
context.tenantId,
|
||||
employee.id,
|
||||
{
|
||||
endpoint: request.body.endpoint,
|
||||
p256dh: request.body.keys.p256dh,
|
||||
auth: request.body.keys.auth,
|
||||
user_agent: request.body.user_agent,
|
||||
},
|
||||
);
|
||||
const saved = await pushDispatcher.saveSubscription(context.tenantId, employee.id, {
|
||||
endpoint: request.body.endpoint,
|
||||
p256dh: request.body.keys.p256dh,
|
||||
auth: request.body.keys.auth,
|
||||
user_agent: request.body.user_agent,
|
||||
});
|
||||
return reply.code(201).send(saved);
|
||||
},
|
||||
);
|
||||
|
|
@ -224,11 +221,7 @@ export async function registerPortalRoutes(
|
|||
if (!employee) {
|
||||
return reply.notFound('Сотрудник для текущего пользователя не найден.');
|
||||
}
|
||||
await pushDispatcher.deleteSubscription(
|
||||
context.tenantId,
|
||||
employee.id,
|
||||
request.body.endpoint,
|
||||
);
|
||||
await pushDispatcher.deleteSubscription(context.tenantId, employee.id, request.body.endpoint);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
|
@ -462,12 +455,14 @@ export async function registerPortalRoutes(
|
|||
context.tenantId,
|
||||
context.actorId,
|
||||
async (client) => {
|
||||
const position = request.body.position?.trim() ?? '';
|
||||
const saved = await client.query(
|
||||
`INSERT INTO staff_employees
|
||||
(id, tenant_id, personnel_number, display_name, position, profession_id, gender, status)
|
||||
VALUES ($1, $2, $3, $4, $5,
|
||||
COALESCE($6, (SELECT id FROM staff_professions
|
||||
WHERE tenant_id = $2 AND name = $5 AND status = 'active' LIMIT 1)),
|
||||
VALUES ($1, $2::varchar(26), $3, $4, $5::text,
|
||||
COALESCE($6, (SELECT id FROM staff_professions
|
||||
WHERE tenant_id = $2::varchar(26) AND name = $8::varchar(160)
|
||||
AND status = 'active' LIMIT 1)),
|
||||
$7, 'active')
|
||||
ON CONFLICT (tenant_id, personnel_number) DO NOTHING
|
||||
RETURNING id, core_user_id, personnel_number, display_name, position, profession_id, gender, status`,
|
||||
|
|
@ -476,9 +471,10 @@ export async function registerPortalRoutes(
|
|||
context.tenantId,
|
||||
personnelNumber,
|
||||
displayName,
|
||||
request.body.position?.trim() ?? '',
|
||||
position,
|
||||
request.body.profession_id ?? null,
|
||||
request.body.gender ?? '',
|
||||
position,
|
||||
],
|
||||
);
|
||||
if (saved.rowCount === 0) return null;
|
||||
|
|
@ -803,25 +799,19 @@ export async function registerPortalRoutes(
|
|||
);
|
||||
if (inserted.rowCount === 0) return { id: eventId, duplicate: true };
|
||||
|
||||
const outboxId = newUlid();
|
||||
const eventType =
|
||||
request.body.event_type === 'check_in' ? 'attendance.clock.in' : 'attendance.clock.out';
|
||||
await client.query(
|
||||
`INSERT INTO staff_outbox
|
||||
(id, tenant_id, event_type, event_version, subject_type, subject_id,
|
||||
actor_type, actor_id, payload, correlation_id, occurred_at)
|
||||
VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`,
|
||||
[
|
||||
outboxId,
|
||||
context.tenantId,
|
||||
eventType,
|
||||
employeeId,
|
||||
context.actorId,
|
||||
{ attendance_event_id: eventId, channel: 'platform_notification' },
|
||||
context.correlationId,
|
||||
occurredAt.toISOString(),
|
||||
],
|
||||
);
|
||||
const outboxId = await insertOutboxEvent(client, {
|
||||
tenantId: context.tenantId,
|
||||
eventType,
|
||||
subjectType: 'Employee',
|
||||
subjectId: employeeId,
|
||||
actorType: 'service',
|
||||
actorId: context.actorId,
|
||||
payload: { attendance_event_id: eventId, channel: 'platform_notification' },
|
||||
correlationId: context.correlationId,
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
});
|
||||
return { id: eventId, outbox_id: outboxId, duplicate: false };
|
||||
},
|
||||
);
|
||||
|
|
@ -1142,25 +1132,20 @@ export async function registerPortalRoutes(
|
|||
context.actorId,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO staff_outbox
|
||||
(id, tenant_id, event_type, event_version, subject_type, subject_id,
|
||||
actor_type, actor_id, payload, correlation_id)
|
||||
VALUES ($1, $2, 'staff.work_session.confirmed', 1, 'WorkSession', $3,
|
||||
'user', $4, $5, $6)`,
|
||||
[
|
||||
newUlid(),
|
||||
context.tenantId,
|
||||
sessionId,
|
||||
context.actorId,
|
||||
{
|
||||
employee_id: current.employee_id,
|
||||
business_date: current.business_date,
|
||||
worked_minutes: workedMinutes,
|
||||
},
|
||||
context.correlationId,
|
||||
],
|
||||
);
|
||||
await insertOutboxEvent(client, {
|
||||
tenantId: context.tenantId,
|
||||
eventType: 'staff.work_session.confirmed',
|
||||
subjectType: 'WorkSession',
|
||||
subjectId: sessionId,
|
||||
actorType: 'user',
|
||||
actorId: context.actorId,
|
||||
payload: {
|
||||
employee_id: current.employee_id,
|
||||
business_date: current.business_date,
|
||||
worked_minutes: workedMinutes,
|
||||
},
|
||||
correlationId: context.correlationId,
|
||||
});
|
||||
}
|
||||
return { kind: 'saved' as const, session: result };
|
||||
},
|
||||
|
|
|
|||
54
src/sync.ts
54
src/sync.ts
|
|
@ -4,11 +4,7 @@ import { parseUlid, type Ulid } from './core/ids.js';
|
|||
import type { Database } from './infrastructure/database.js';
|
||||
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
|
||||
import type { IdentityPort } from './core/ports.js';
|
||||
import {
|
||||
SyncAuthError,
|
||||
SyncService,
|
||||
SyncValidationError,
|
||||
} from './domain/sync/service.js';
|
||||
import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
|
||||
import { InvalidCursorError } from './domain/sync/cursor.js';
|
||||
import type { SyncCommand } from './domain/sync/types.js';
|
||||
|
||||
|
|
@ -97,14 +93,18 @@ export async function registerSyncRoutes(
|
|||
!context.permissions.has('staff:attendance:manage') &&
|
||||
!context.permissions.has('staff:sync:manage')
|
||||
) {
|
||||
throw request.server.httpErrors.forbidden('Недостаточно прав для управления устройствами синхронизации');
|
||||
throw request.server.httpErrors.forbidden(
|
||||
'Недостаточно прав для управления устройствами синхронизации',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
} catch (error: unknown) {
|
||||
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
|
||||
throw error;
|
||||
}
|
||||
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
|
||||
throw request.server.httpErrors.unauthorized(
|
||||
'Требуется действующий ключ доступа руководителя',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -169,7 +169,11 @@ export async function registerSyncRoutes(
|
|||
},
|
||||
async (request, reply) => {
|
||||
const manager = await requireManagerContext(request);
|
||||
const result = await syncService.registerDevice(manager.tenantId, manager.actorId, request.body);
|
||||
const result = await syncService.registerDevice(
|
||||
manager.tenantId,
|
||||
manager.actorId,
|
||||
request.body,
|
||||
);
|
||||
return reply.code(201).send(result);
|
||||
},
|
||||
);
|
||||
|
|
@ -257,7 +261,8 @@ export async function registerSyncRoutes(
|
|||
{
|
||||
schema: {
|
||||
tags: ['Sync'],
|
||||
summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
||||
summary:
|
||||
'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -279,7 +284,15 @@ export async function registerSyncRoutes(
|
|||
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' },
|
||||
|
|
@ -330,7 +343,14 @@ export async function registerSyncRoutes(
|
|||
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', default: 1 },
|
||||
|
|
@ -387,7 +407,11 @@ export async function registerSyncRoutes(
|
|||
const token = extractBearerToken(request);
|
||||
const tenantId = resolveTenantId(request);
|
||||
const device = await syncService.authenticateDevice(token, tenantId);
|
||||
const results = await syncService.push(device.tenant_id, device, request.body?.commands ?? []);
|
||||
const results = await syncService.push(
|
||||
device.tenant_id,
|
||||
device,
|
||||
request.body?.commands ?? [],
|
||||
);
|
||||
return reply.code(200).send({ results });
|
||||
} catch (error) {
|
||||
handleSyncError(error, request);
|
||||
|
|
@ -428,7 +452,11 @@ export async function registerSyncRoutes(
|
|||
const token = extractBearerToken(request);
|
||||
const tenantId = resolveTenantId(request);
|
||||
const device = await syncService.authenticateDevice(token, tenantId);
|
||||
const result = await syncService.acknowledge(device.tenant_id, device.id, request.body.cursor);
|
||||
const result = await syncService.acknowledge(
|
||||
device.tenant_id,
|
||||
device.id,
|
||||
request.body.cursor,
|
||||
);
|
||||
return reply.code(200).send(result);
|
||||
} catch (error) {
|
||||
handleSyncError(error, request);
|
||||
|
|
|
|||
126
tests/integration/live-postgres-remediation.test.ts
Normal file
126
tests/integration/live-postgres-remediation.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../../src/app.js';
|
||||
import { loadConfig } from '../../src/config.js';
|
||||
import { newUlid } from '../../src/core/ids.js';
|
||||
import { insertOutboxEvent } from '../../src/core/outbox.js';
|
||||
import { Database } from '../../src/infrastructure/database.js';
|
||||
|
||||
const connectionString = process.env.DATABASE_TEST_URL;
|
||||
const isolatedDatabase = process.env.DATABASE_TEST_ISOLATED === 'true';
|
||||
const guardToken = process.env.DATABASE_TEST_GUARD_TOKEN;
|
||||
const describeDatabase =
|
||||
connectionString && isolatedDatabase && guardToken ? describe : describe.skip;
|
||||
|
||||
describeDatabase('live PostgreSQL audit remediations', () => {
|
||||
it('creates an employee and completes the outbox-producing shift and payroll workflow', async () => {
|
||||
const database = new Database(connectionString!);
|
||||
const tenantId = newUlid();
|
||||
const actorId = newUlid();
|
||||
const managerToken = 'm'.repeat(64);
|
||||
const orgUnitId = newUlid();
|
||||
const sessionId = newUlid();
|
||||
const currentDatabase = await database.pool.query<{ name: string }>(
|
||||
'SELECT current_database() AS name',
|
||||
);
|
||||
expect(currentDatabase.rows[0]?.name).toMatch(/^krona_staff_audit_[a-z0-9_]+$/);
|
||||
const role = await database.pool.query<{ rolsuper: boolean; rolbypassrls: boolean }>(
|
||||
'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user',
|
||||
);
|
||||
expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
|
||||
const guard = await database.pool.query<{ marker: string }>(
|
||||
'SELECT marker FROM krona_staff_test_guard WHERE marker = $1',
|
||||
[guardToken],
|
||||
);
|
||||
expect(guard.rows).toEqual([{ marker: guardToken }]);
|
||||
const probeOutboxId = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||
insertOutboxEvent(client, {
|
||||
tenantId,
|
||||
eventType: 'staff.audit_probe.created',
|
||||
subjectType: 'AuditProbe',
|
||||
subjectId: newUlid(),
|
||||
actorType: 'user',
|
||||
actorId,
|
||||
payload: { source: 'live-postgres-test' },
|
||||
correlationId: newUlid(),
|
||||
}),
|
||||
);
|
||||
const probe = await database.tenantTransaction(tenantId, actorId, (client) =>
|
||||
client.query<{ occurred_at: Date }>('SELECT occurred_at FROM staff_outbox WHERE id = $1', [
|
||||
probeOutboxId,
|
||||
]),
|
||||
);
|
||||
expect(probe.rows[0]?.occurred_at).toBeTruthy();
|
||||
|
||||
const app = await buildApp({
|
||||
config: loadConfig({
|
||||
DATABASE_URL: connectionString!,
|
||||
STANDALONE_TENANT_ID: tenantId,
|
||||
STANDALONE_ACTOR_ID: actorId,
|
||||
STANDALONE_MANAGER_TOKEN: managerToken,
|
||||
STANDALONE_TERMINAL_TOKEN: 't'.repeat(64),
|
||||
STANDALONE_ACCESS: 'manager',
|
||||
LOG_LEVEL: 'silent',
|
||||
NODE_ENV: 'test',
|
||||
}),
|
||||
database,
|
||||
});
|
||||
const authorization = { authorization: `Bearer ${managerToken}` };
|
||||
try {
|
||||
const employee = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/staff/employees',
|
||||
headers: authorization,
|
||||
payload: {
|
||||
personnel_number: `AUDIT-${newUlid()}`,
|
||||
display_name: 'Тестовый сотрудник',
|
||||
position: 'Тестовая профессия',
|
||||
},
|
||||
});
|
||||
expect(employee.statusCode, employee.body).toBe(201);
|
||||
const employeeId = employee.json<{ id: string }>().id;
|
||||
|
||||
await database.tenantTransaction(tenantId, actorId, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_org_units (id, tenant_id, code, name)
|
||||
VALUES ($1, $2, $3, 'Тестовая точка')`,
|
||||
[orgUnitId, tenantId, `audit-${newUlid().toLowerCase()}`],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO staff_work_sessions
|
||||
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at,
|
||||
worked_minutes, status)
|
||||
VALUES ($1, $2, $3, $4, '2040-01-15', '2040-01-15T01:00:00Z',
|
||||
'2040-01-15T09:00:00Z', 480, 'pending_review')`,
|
||||
[sessionId, tenantId, employeeId, orgUnitId],
|
||||
);
|
||||
});
|
||||
|
||||
const confirmed = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/staff/work-sessions/${sessionId}`,
|
||||
headers: authorization,
|
||||
payload: { status: 'confirmed' },
|
||||
});
|
||||
expect(confirmed.statusCode).toBe(200);
|
||||
|
||||
const calculated = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/staff/payroll/calculate',
|
||||
headers: authorization,
|
||||
payload: { month: '2040-01' },
|
||||
});
|
||||
expect(calculated.statusCode).toBe(200);
|
||||
const runId = calculated.json<{ run_id: string }>().run_id;
|
||||
|
||||
const approved = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/staff/payroll/runs/${runId}/approve`,
|
||||
headers: authorization,
|
||||
payload: {},
|
||||
});
|
||||
expect(approved.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -29,7 +29,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [tenantA]);
|
||||
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [
|
||||
tenantA,
|
||||
]);
|
||||
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]);
|
||||
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantA]);
|
||||
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantA]);
|
||||
|
|
@ -253,7 +255,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
});
|
||||
|
||||
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]),
|
||||
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [
|
||||
deviceIdA,
|
||||
]),
|
||||
);
|
||||
expect(cpVisibleToB.rowCount).toBe(0);
|
||||
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ describe('runtime modes', () => {
|
|||
}).PLATFORM_GATEWAY_URL,
|
||||
).toBeUndefined());
|
||||
it('requires VAPID keys to be configured together if provided', () => {
|
||||
expect(() =>
|
||||
loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' }),
|
||||
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
|
||||
expect(() =>
|
||||
loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' }),
|
||||
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
|
||||
expect(() => loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' })).toThrow(
|
||||
/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/,
|
||||
);
|
||||
expect(() => loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' })).toThrow(
|
||||
/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/,
|
||||
);
|
||||
const config = loadConfig({
|
||||
...base,
|
||||
VAPID_PUBLIC_KEY: 'pubkey',
|
||||
|
|
@ -46,4 +46,3 @@ describe('runtime modes', () => {
|
|||
expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ function decryptPushPayload(
|
|||
recipient.publicKey,
|
||||
senderPublicKey,
|
||||
]);
|
||||
const prk = Buffer.from(
|
||||
crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32),
|
||||
);
|
||||
const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32));
|
||||
|
||||
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
|
||||
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16));
|
||||
|
|
@ -79,12 +77,12 @@ describe('WebPush VAPID key management', () => {
|
|||
});
|
||||
|
||||
it('rejects mismatched VAPID configuration when only one key is set', () => {
|
||||
expect(() =>
|
||||
resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' }),
|
||||
).toThrow(/must be configured together/);
|
||||
expect(() =>
|
||||
resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' }),
|
||||
).toThrow(/must be configured together/);
|
||||
expect(() => resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' })).toThrow(
|
||||
/must be configured together/,
|
||||
);
|
||||
expect(() => resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' })).toThrow(
|
||||
/must be configured together/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -172,9 +170,9 @@ describe('WebPush RFC 8291 / RFC 8188 AES-128-GCM encryption', () => {
|
|||
});
|
||||
|
||||
it('rejects invalid recipient keys with clear error', () => {
|
||||
expect(() =>
|
||||
encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' }),
|
||||
).toThrow(/Invalid recipient/);
|
||||
expect(() => encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' })).toThrow(
|
||||
/Invalid recipient/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -377,7 +375,11 @@ describe('PushDispatcher idempotent outbox handling', () => {
|
|||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Push delivery is tracked independently from platform event publication.
|
||||
expect(queries.some((q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'))).toBe(true);
|
||||
expect(
|
||||
queries.some(
|
||||
(q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
56
tests/unit/outbox.test.ts
Normal file
56
tests/unit/outbox.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { insertOutboxEvent } from '../../src/core/outbox.js';
|
||||
import { parseUlid } from '../../src/core/ids.js';
|
||||
|
||||
describe('transactional outbox helper', () => {
|
||||
it('writes a complete platform event envelope with an occurrence time', async () => {
|
||||
const query = vi.fn(async (...args: [string, readonly unknown[] | undefined]) => {
|
||||
void args;
|
||||
return { rows: [], rowCount: 1 };
|
||||
});
|
||||
const tenantId = parseUlid('01J0000000000000000000000A');
|
||||
const actorId = parseUlid('01J0000000000000000000001A');
|
||||
const subjectId = parseUlid('01J0000000000000000000002A');
|
||||
const correlationId = parseUlid('01J0000000000000000000003A');
|
||||
|
||||
await insertOutboxEvent({ query } as never, {
|
||||
tenantId,
|
||||
eventType: 'staff.test.created',
|
||||
subjectType: 'TestSubject',
|
||||
subjectId,
|
||||
actorType: 'user',
|
||||
actorId,
|
||||
payload: { state: 'created' },
|
||||
correlationId,
|
||||
});
|
||||
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
const [sql, parameters] = query.mock.calls[0] as unknown as [string, unknown[]];
|
||||
expect(sql).toContain('causation_id, occurred_at');
|
||||
expect(parameters[8]).toBe('{"state":"created"}');
|
||||
expect(parameters[11]).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('preserves a source occurrence time for imported or terminal events', async () => {
|
||||
const query = vi.fn(async (...args: [string, readonly unknown[] | undefined]) => {
|
||||
void args;
|
||||
return { rows: [], rowCount: 1 };
|
||||
});
|
||||
const occurredAt = '2026-09-24T01:00:00.000Z';
|
||||
|
||||
await insertOutboxEvent({ query } as never, {
|
||||
tenantId: parseUlid('01J0000000000000000000000A'),
|
||||
eventType: 'attendance.clock.in',
|
||||
subjectType: 'Employee',
|
||||
subjectId: parseUlid('01J0000000000000000000002A'),
|
||||
actorType: 'service',
|
||||
actorId: parseUlid('01J0000000000000000000001A'),
|
||||
payload: { source: 'terminal' },
|
||||
correlationId: parseUlid('01J0000000000000000000003A'),
|
||||
occurredAt,
|
||||
});
|
||||
|
||||
const parameters = query.mock.calls[0]?.[1] as readonly unknown[];
|
||||
expect(parameters[11]).toBe(occurredAt);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { portalCssEnhanced, portalHtml, portalJsEnhanced, portalManifest } from '../../src/portal-ui.js';
|
||||
import {
|
||||
portalCssEnhanced,
|
||||
portalHtml,
|
||||
portalJsEnhanced,
|
||||
portalManifest,
|
||||
} from '../../src/portal-ui.js';
|
||||
|
||||
describe('Krona Staff workspace shell', () => {
|
||||
it('exposes the operational workspace sections with deep routes', () => {
|
||||
|
|
@ -33,6 +38,9 @@ describe('Krona Staff workspace shell', () => {
|
|||
expect(portalJsEnhanced).toContain("'/staff/attendance':'attendance-view'");
|
||||
expect(portalJsEnhanced).toContain("'/staff/reports':'reports-view'");
|
||||
expect(portalJsEnhanced).toContain("window.addEventListener('popstate'");
|
||||
expect(JSON.parse(portalManifest)).toMatchObject({ start_url: '/staff/dashboard', scope: '/staff' });
|
||||
expect(JSON.parse(portalManifest)).toMatchObject({
|
||||
start_url: '/staff/dashboard',
|
||||
scope: '/staff',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -61,8 +61,12 @@ describe('payroll foundation migration', () => {
|
|||
});
|
||||
|
||||
it('enables and forces RLS with a tenant policy for every listed payroll table', () => {
|
||||
expect(sql).toContain("EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table)");
|
||||
expect(sql).toContain("EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)");
|
||||
expect(sql).toContain(
|
||||
"EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table)",
|
||||
);
|
||||
expect(sql).toContain(
|
||||
"EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)",
|
||||
);
|
||||
expect(sql).toContain("target_table || '_tenant_isolation', target_table");
|
||||
});
|
||||
|
||||
|
|
@ -97,9 +101,13 @@ describe('payroll foundation migration', () => {
|
|||
it('enforces tenant isolation and RLS on staff_payroll_ledger with select and insert policies', () => {
|
||||
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY');
|
||||
expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY');
|
||||
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger');
|
||||
expect(sql).toContain(
|
||||
'CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger',
|
||||
);
|
||||
expect(sql).toContain("FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))");
|
||||
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger');
|
||||
expect(sql).toContain(
|
||||
'CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger',
|
||||
);
|
||||
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
|
||||
expect(sql).toContain("created_by = current_setting('app.user_id', true)");
|
||||
});
|
||||
|
|
@ -165,9 +173,7 @@ describe('employee push notifications migration', () => {
|
|||
expect(sql).toContain(
|
||||
'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE',
|
||||
);
|
||||
expect(sql).toContain('FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE');
|
||||
});
|
||||
|
||||
it('grants required operations on both push tables to krona_staff_app', () => {
|
||||
|
|
@ -217,17 +223,27 @@ describe('offline sync backend migration', () => {
|
|||
});
|
||||
|
||||
it('grants required operations on all sync tables and sequence to krona_staff_app', () => {
|
||||
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_devices TO krona_staff_app;');
|
||||
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_change_log TO krona_staff_app;');
|
||||
expect(sql).toContain('GRANT USAGE, SELECT ON SEQUENCE staff_sync_change_log_cursor_seq_seq TO krona_staff_app;');
|
||||
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_command_inbox TO krona_staff_app;');
|
||||
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_device_checkpoints TO krona_staff_app;');
|
||||
expect(sql).toContain(
|
||||
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_devices TO krona_staff_app;',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_change_log TO krona_staff_app;',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'GRANT USAGE, SELECT ON SEQUENCE staff_sync_change_log_cursor_seq_seq TO krona_staff_app;',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_command_inbox TO krona_staff_app;',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_device_checkpoints TO krona_staff_app;',
|
||||
);
|
||||
});
|
||||
|
||||
it('declares and grants retention cleanup function', () => {
|
||||
expect(sql).toContain('CREATE OR REPLACE FUNCTION staff_sync_cleanup_retention');
|
||||
expect(sql).toContain('GRANT EXECUTE ON FUNCTION staff_sync_cleanup_retention(INTEGER) TO krona_staff_app;');
|
||||
expect(sql).toContain(
|
||||
'GRANT EXECUTE ON FUNCTION staff_sync_cleanup_retention(INTEGER) TO krona_staff_app;',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ const options = {
|
|||
|
||||
describe('attendance work-session reconciliation', () => {
|
||||
it('sums several in/out intervals without inventing time between them', () => {
|
||||
const result = reconcileWorkSession([
|
||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
||||
{ id: '2', type: 'check_out', occurredAt: '2026-09-24T05:00:00.000Z' },
|
||||
{ id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' },
|
||||
{ id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' },
|
||||
], options);
|
||||
const result = reconcileWorkSession(
|
||||
[
|
||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
||||
{ id: '2', type: 'check_out', occurredAt: '2026-09-24T05:00:00.000Z' },
|
||||
{ id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' },
|
||||
{ id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' },
|
||||
],
|
||||
options,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
startedAt: '2026-09-24T01:00:00.000Z',
|
||||
|
|
@ -25,28 +28,38 @@ describe('attendance work-session reconciliation', () => {
|
|||
});
|
||||
|
||||
it('keeps the first check-in and flags a repeated check-in', () => {
|
||||
const result = reconcileWorkSession([
|
||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
||||
{ id: '2', type: 'check_in', occurredAt: '2026-09-24T01:05:00.000Z' },
|
||||
{ id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
|
||||
], options);
|
||||
const result = reconcileWorkSession(
|
||||
[
|
||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
||||
{ id: '2', type: 'check_in', occurredAt: '2026-09-24T01:05:00.000Z' },
|
||||
{ id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
|
||||
],
|
||||
options,
|
||||
);
|
||||
|
||||
expect(result?.workedMinutes).toBe(480);
|
||||
expect(result?.anomalies).toEqual(['repeated_check_in']);
|
||||
});
|
||||
|
||||
it('keeps a recent unfinished shift open', () => {
|
||||
const result = reconcileWorkSession([
|
||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' },
|
||||
], options);
|
||||
const result = reconcileWorkSession(
|
||||
[{ id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' }],
|
||||
options,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'open', endedAt: null, workedMinutes: 0, anomalies: [] });
|
||||
expect(result).toMatchObject({
|
||||
status: 'open',
|
||||
endedAt: null,
|
||||
workedMinutes: 0,
|
||||
anomalies: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('flags an old unfinished shift without estimating payable minutes', () => {
|
||||
const result = reconcileWorkSession([
|
||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
|
||||
], options);
|
||||
const result = reconcileWorkSession(
|
||||
[{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' }],
|
||||
options,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'pending_review',
|
||||
|
|
@ -57,9 +70,10 @@ describe('attendance work-session reconciliation', () => {
|
|||
});
|
||||
|
||||
it('auto-closes only after the scheduler cutoff and leaves an audit-worthy anomaly', () => {
|
||||
const result = reconcileWorkSession([
|
||||
{ id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' },
|
||||
], { ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' });
|
||||
const result = reconcileWorkSession(
|
||||
[{ id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' }],
|
||||
{ ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
endedAt: '2026-09-23T19:00:00.000Z',
|
||||
|
|
@ -70,9 +84,10 @@ describe('attendance work-session reconciliation', () => {
|
|||
});
|
||||
|
||||
it('records a check-out without check-in as an anomaly with zero minutes', () => {
|
||||
const result = reconcileWorkSession([
|
||||
{ id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
|
||||
], options);
|
||||
const result = reconcileWorkSession(
|
||||
[{ id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' }],
|
||||
options,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
startedAt: null,
|
||||
|
|
@ -84,10 +99,11 @@ describe('attendance work-session reconciliation', () => {
|
|||
});
|
||||
|
||||
it('rejects invalid timestamps and thresholds', () => {
|
||||
expect(() => reconcileWorkSession([
|
||||
{ id: '1', type: 'check_in', occurredAt: 'not-a-date' },
|
||||
], options)).toThrow('event 1 must be an ISO date-time');
|
||||
expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 }))
|
||||
.toThrow('openShiftThresholdMinutes must be a positive integer');
|
||||
expect(() =>
|
||||
reconcileWorkSession([{ id: '1', type: 'check_in', occurredAt: 'not-a-date' }], options),
|
||||
).toThrow('event 1 must be an ISO date-time');
|
||||
expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 })).toThrow(
|
||||
'openShiftThresholdMinutes must be a positive integer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue