fix: resolve production readiness blockers, tenant isolation and e2e defects
This commit is contained in:
parent
89621ea069
commit
e6dd4b743c
17 changed files with 350 additions and 222 deletions
|
|
@ -24,7 +24,7 @@ docker compose up --build app postgres
|
|||
- `npm run db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`.
|
||||
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
|
||||
|
||||
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), запуск и чек-лист боевого тестирования: [docs/RUN-MANUAL-TESTING.md](docs/RUN-MANUAL-TESTING.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
|
||||
## Расчётный контур
|
||||
|
||||
|
|
|
|||
156
docs/RUN-MANUAL-TESTING.md
Normal file
156
docs/RUN-MANUAL-TESTING.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Руководство по развёртыванию и чек-лист боевого тестирования Krona Staff
|
||||
|
||||
## 1. Назначение и архитектурные инварианты
|
||||
|
||||
Krona Staff — автономно развёртываемый блок Krona Business Platform, отвечающий за учёт рабочего времени, терминалы посещаемости и управленческий расчёт зарплаты.
|
||||
|
||||
### Ключевые инварианты (AGENTS.md)
|
||||
|
||||
1. **Разделение доменов**: Core владеет Tenant, User, Role, SSO, Platform Settings, Files, Notifications. Staff владеет Employee и HR-доменом (`User != Employee`).
|
||||
2. **Изоляция арендаторов**: Все таблицы содержат `tenant_id` (ULID 26 символов). В PostgreSQL включен и принудительно активирован Row-Level Security (`FORCE ROW LEVEL SECURITY`). Запросы выполняются через tenant-scoped транзакции с выставлением `app.tenant_id`.
|
||||
3. **Разделение ролей БД**:
|
||||
- `krona_migrator` — владелец схемы и таблиц, выполняет миграции DDL, `NOSUPERUSER NOBYPASSRLS`.
|
||||
- `krona_staff_app` — runtime-пользователь приложения с минимальными DML правами (SELECT, INSERT, UPDATE, DELETE на прикладные таблицы; нет прав на `schema_migrations`, нет прав DDL).
|
||||
4. **Типы данных**:
|
||||
- Идентификаторы — ULID (`VARCHAR(26)`).
|
||||
- Денежные суммы — `BIGINT` в минимальных единицах (копейках/minor units) + ISO 4217 код валюты (например, `'RUB'`).
|
||||
- Временные метки — UTC (`TIMESTAMPTZ`).
|
||||
5. **Надёжность**: Транзакционный Outbox, идемпотентность по `Idempotency-Key`, CSRF-защита сессионных cookie (`Origin`/`Sec-Fetch-Site`), Rate-limiting ключей входа.
|
||||
|
||||
---
|
||||
|
||||
## 2. Развёртывание и запуск
|
||||
|
||||
### 2.1. Системные требования
|
||||
|
||||
- **ОС**: Linux (Ubuntu 22.04+/Debian 12+) или Windows Server 2022+ / Windows 11 (для сборки терминала).
|
||||
- **Среда выполнения**: Node.js 22 LTS, npm 10+.
|
||||
- **База данных**: PostgreSQL 17 (поддерживается 17.0+).
|
||||
- **Сеть**: Порт 8080 (HTTP) или 8453 (HTTPS пилот).
|
||||
|
||||
### 2.2. Запуск через Docker Compose (рекомендуемый пилот)
|
||||
|
||||
```bash
|
||||
# 1. Перейдите в каталог deploy/pilot
|
||||
cd deploy/pilot
|
||||
|
||||
# 2. Задайте ULID организации из Krona Business Platform
|
||||
export STANDALONE_TENANT_ID="01J0000000000000000000000A"
|
||||
|
||||
# 3. Выполните скрипт первичной инициализации:
|
||||
# - Сгенерирует стойкие пароли БД, токены менеджера и терминала в secrets/
|
||||
# - Сгенерирует ключи Web Push (VAPID)
|
||||
# - Создаст самоподписанный TLS-сертификат для терминала
|
||||
./setup.sh
|
||||
|
||||
# 4. Запустите стек (PostgreSQL + мигратор + app)
|
||||
docker compose up -d --build
|
||||
|
||||
# 5. Проверьте запуск контейнеров и миграций
|
||||
docker compose ps
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
### 2.3. Запуск без Docker (Bare-Metal / Dev стенд)
|
||||
|
||||
1. **Создайте базу данных и роли в PostgreSQL**:
|
||||
|
||||
```sql
|
||||
-- Под пользователем postgres (DBA):
|
||||
CREATE ROLE krona_migrator LOGIN PASSWORD 'migrator_secure_password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
||||
CREATE ROLE krona_staff_app LOGIN PASSWORD 'app_secure_password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
||||
|
||||
CREATE DATABASE krona_staff OWNER krona_migrator ENCODING 'UTF8';
|
||||
\c krona_staff
|
||||
ALTER SCHEMA public OWNER TO krona_migrator;
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
REVOKE ALL ON DATABASE krona_staff FROM PUBLIC;
|
||||
GRANT CONNECT ON DATABASE krona_staff TO krona_staff_app;
|
||||
```
|
||||
|
||||
2. **Настройте переменные окружения (`.env`)**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Укажите в `.env`:
|
||||
|
||||
```ini
|
||||
PORT=8080
|
||||
NODE_ENV=production
|
||||
STANDALONE_TENANT_ID=01J0000000000000000000000A
|
||||
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
||||
STANDALONE_MANAGER_TOKEN=your_secure_manager_token_hex_or_ulid
|
||||
STANDALONE_TERMINAL_TOKEN=your_secure_terminal_token_hex_or_ulid
|
||||
DATABASE_URL=postgres://krona_staff_app:app_secure_password@127.0.0.1:5432/krona_staff
|
||||
DATABASE_MIGRATION_URL=postgres://krona_migrator:migrator_secure_password@127.0.0.1:5432/krona_staff
|
||||
```
|
||||
|
||||
3. **Сгенерируйте VAPID ключи**:
|
||||
|
||||
```bash
|
||||
node scripts/generate-vapid-keys.mjs >> .env
|
||||
```
|
||||
|
||||
4. **Примените миграции**:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
5. **Соберите и запустите приложение**:
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Чек-лист ручного боевого тестирования владельцем
|
||||
|
||||
Каждый шаг должен быть выполнен последовательно. Ожидаемый результат сверяется с фактическим.
|
||||
|
||||
| № | Шаг / Сценарий | Действие / HTTP-запрос | Ожидаемый результат | Статус |
|
||||
| ------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | :----: |
|
||||
| **1** | **Системные проверки (Health)** | `GET /api/v1/staff/live`<br>`GET /api/v1/staff/ready`<br>`GET /api/v1/staff/version` | HTTP 200 на всех эндпоинтах. `ready` подтверждает соединение с БД под ролью `krona_staff_app`. `version` возвращает `runtime: standalone` и semver. | [ ] |
|
||||
| **2** | **Контракт OpenAPI** | `GET /api/v1/staff/openapi.json` | HTTP 200, возвращается валидная спецификация OpenAPI 3.1 со всеми роутами модуля. | [ ] |
|
||||
| **3** | **Защита доступа (401)** | `GET /api/v1/staff/portal` без заголовка Authorization | HTTP 401 Unauthorized. Ошибки с деталями SQL отсутствуют. | [ ] |
|
||||
| **4** | **Защита от CSRF** | `POST /api/v1/staff/portal/employees` с cookie сессии, но без заголовка `Origin` или с чужим Origin | HTTP 403 Forbidden (CSRF token missing/invalid). | [ ] |
|
||||
| **5** | **Защита от перебора паролей (429)** | 35 последовательных запросов с неверным токеном/ключом | После исчерпания лимита возвращается HTTP 429 Too Many Requests с заголовком `Retry-After`. | [ ] |
|
||||
| **6** | **Создание сотрудника** | `POST /api/v1/staff/employees`<br>Headers: `Authorization: Bearer <MANAGER_TOKEN>`<br>Body: `{"personnel_number": "EMP-001", "display_name": "Иванов Иван", "profession_code": "cook"}` | HTTP 201 Created. Создан сотрудник, присвоен ULID, запись зафиксирована в `staff_employees`. | [ ] |
|
||||
| **7** | **Проверка справочника профессий** | `GET /api/v1/staff/professions` | HTTP 200, список активных профессий (`cook`, `cashier`, `barista`, `waiter` и др.). | [ ] |
|
||||
| **8** | **Назначение тарифной ставки сотруднику** | `POST /api/v1/staff/payroll/assignments`<br>Body: привязка правила (почасовая ставка 350 руб/час = 35000 minor units). | HTTP 201 Created. Версионированное правило сохранено в `staff_pay_assignments`. | [ ] |
|
||||
| **9** | **Отметка прихода через терминал** | `POST /api/v1/staff/attendance/events`<br>Headers: `Authorization: Bearer <TERMINAL_TOKEN>`<br>Body: `{"event_id": "<ULID>", "employee_id": "<EMP_ID>", "event_type": "check_in", "occurred_at": "<NOW_ISO>"}` | HTTP 201 Created. Событие записано в `staff_attendance_events`. Отправлено событие в outbox. | [ ] |
|
||||
| **10** | **Идемпотентность отметки** | Повтор запроса шага 9 с тем же `event_id` и `source_event_id` | HTTP 200 OK (или 201), дубликат не создаётся, количество записей не удваивается. | [ ] |
|
||||
| **11** | **Отметка ухода через терминал** | `POST /api/v1/staff/attendance/events` с `event_type: "check_out"` спустя 8 часов | HTTP 201 Created. Формируется завершённая рабочая смена. | [ ] |
|
||||
| **12** | **Подтверждение смены менеджером** | `POST /api/v1/staff/work-sessions/:id/confirm` | HTTP 200 OK. Смена переходит в статус `confirmed`, часы готовы к начислению. | [ ] |
|
||||
| **13** | **Авторасчёт ведомости зарплаты** | `POST /api/v1/staff/payroll/runs/auto-calc`<br>Body: период с 1 по 15 число месяца. | HTTP 201 Created. Начислено: 8 ч × 350 руб = 2800 руб (280000 minor units). Сохранена расшифровка формулы расчёта. | [ ] |
|
||||
| **14** | **Согласование ведомости бухгалтером** | `POST /api/v1/staff/payroll/batches/:id/approve` | HTTP 200 OK. Статус пакета переходит в `approved`. | [ ] |
|
||||
| **15** | **Подписание ведомости директором** | `POST /api/v1/staff/payroll/batches/:id/sign`<br>Body: `{"signature": "director_sign_key", "note": "Выплата за первую половину месяца"}` | HTTP 200 OK. Пакет подписан. Созданы строки начислений в `staff_payroll_ledger` с полем `note` (без ошибки 500!). | [ ] |
|
||||
| **16** | **Проверка реестра выплат (Ledger)** | `GET /api/v1/staff/payroll/ledger` | HTTP 200 OK. Отображаются начисления и выплаты, сальдо корректно сходится. | [ ] |
|
||||
| **17** | **Web Push уведомления сотруднику** | `POST /api/v1/staff/portal/push-subscriptions` с валидным FCM/W3C endpoint. | HTTP 201 Created. Подписка сохранена. При наступлении события формируется delivery без ложных блокировок хоста. | [ ] |
|
||||
| **18** | **Уведомления директору без дублей** | `GET /api/v1/staff/notifications/unread` под учётной записью директора | HTTP 200 OK. Каждому директору отдаются ровно его уведомления без дублирования на каждого получателя. | [ ] |
|
||||
| **19** | **Офлайн-синхронизация: регистрация терминала** | `POST /api/v1/staff/sync/devices/register` с токеном менеджера | HTTP 201 Created. Возвращается токен устройства `kstaff_dev_...`. Токен сохраняется в терминале. | [ ] |
|
||||
| **20** | **Офлайн-синхронизация: начальный Pull (C0)** | `GET /api/v1/staff/sync/pull` с токеном устройства (без курсора) | HTTP 200 OK. Возвращаются справочники (сотрудники, оргструктура, тарифы) и курсор `cursor_ulid`. | [ ] |
|
||||
| **21** | **Офлайн-синхронизация: пакет команд Push** | `POST /api/v1/staff/sync/push` с массивом офлайн-отметок посещаемости | HTTP 200 OK. Все корректные команды применены, битые команды получают понятную ошибку, статус транзакции не ломает весь пакет. | [ ] |
|
||||
| **22** | **Отзыв прав уволенного сотрудника** | Перевод сотрудника в статус `fired` / деактивация доступа в `staff_access_grants` | Попытка входа уволенного сотрудника немедленно возвращает HTTP 401 Unauthorized. | [ ] |
|
||||
| **23** | **Проверка утилиты миграции арендатора (Rehome)** | `node scripts/rehome-tenant.mjs --dry-run --source <OLD_ULID> --target <NEW_ULID>` | Утилита производит корректное сравнение количества строк по всем таблицам (`Number`) без сбоев типов и ошибок внешних ключей. | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## 4. Диагностика и устранение неполадок
|
||||
|
||||
1. **Ошибка `401 Unauthorized` при запросе от терминала**:
|
||||
- Проверьте, передан ли заголовок `Authorization: Bearer <STANDALONE_TERMINAL_TOKEN>` или токен зарегистрированного устройства.
|
||||
2. **Ошибка `403 Forbidden` при запросах из веб-интерфейса**:
|
||||
- Убедитесь, что запрос отправляется с доверенного Origin (`localhost`, `127.0.0.1` или IP пилота). Сессионные мутирующие запросы требуют валидный Origin.
|
||||
3. **Проверка логов транзакций и RLS**:
|
||||
```bash
|
||||
# Просмотр логов контейнера
|
||||
docker compose logs -f app
|
||||
```
|
||||
4. **Проверка состояния миграций БД**:
|
||||
```bash
|
||||
node scripts/check-migrations.mjs
|
||||
```
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
|
|
@ -1074,9 +1074,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1095,9 +1092,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1116,9 +1110,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1137,9 +1128,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1158,9 +1146,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1179,9 +1164,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1507,9 +1489,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1691,9 +1673,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ajv/node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -1776,9 +1758,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2265,9 +2247,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
|
||||
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
|
||||
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -2662,9 +2644,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -2900,9 +2882,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2925,9 +2904,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2950,9 +2926,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2975,9 +2948,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ export function parseTenantRehomeArgs(args) {
|
|||
return result;
|
||||
}
|
||||
|
||||
|
||||
export function validateBackupEvidenceObject(evidence) {
|
||||
const backup = evidence?.backup;
|
||||
const restore = evidence?.restoreDrill;
|
||||
|
|
@ -266,7 +265,7 @@ async function tableCounts(client, tables, tenantId) {
|
|||
`SELECT count(*)::text AS row_count FROM ${qualified('public', table)} WHERE tenant_id = $1`,
|
||||
[tenantId],
|
||||
);
|
||||
counts[table] = rows[0].row_count;
|
||||
counts[table] = Number(rows[0].row_count);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
|
@ -354,7 +353,6 @@ export async function rehomeTenant(client, options) {
|
|||
return report;
|
||||
}
|
||||
|
||||
|
||||
const foreignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
||||
const ledgerBefore = await ledgerTotals(client, source);
|
||||
for (const fk of foreignKeys) {
|
||||
|
|
@ -522,7 +520,6 @@ async function main() {
|
|||
} else {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export class StandaloneIdentityAdapter implements IdentityPort {
|
|||
} else {
|
||||
throw new Error('Invalid standalone access token');
|
||||
}
|
||||
} else if (access === 'manager') {
|
||||
throw new Error('Standalone access tokens are not configured');
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ export async function buildApp({
|
|||
const supplied = request.headers['x-correlation-id']?.toString();
|
||||
return supplied && ulidPattern.test(supplied) ? supplied : newUlid();
|
||||
},
|
||||
ajv: {
|
||||
customOptions: {
|
||||
removeAdditional: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.register(helmet);
|
||||
await app.register(sensible);
|
||||
|
|
|
|||
|
|
@ -9,4 +9,3 @@ export interface RequestContext {
|
|||
role?: string;
|
||||
orgUnitIds?: readonly string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ export class PushConflictError extends Error {
|
|||
}
|
||||
|
||||
export interface PushDispatcherOptions {
|
||||
|
||||
database: Database;
|
||||
vapidKeys: VapidKeys;
|
||||
systemActorId: string;
|
||||
|
|
@ -107,7 +106,9 @@ export class PushDispatcher {
|
|||
const userAgent = (input.user_agent ?? '').trim();
|
||||
|
||||
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
||||
throw new PushValidationError('Endpoint, p256dh, and auth are required for WebPush subscription');
|
||||
throw new PushValidationError(
|
||||
'Endpoint, p256dh, and auth are required for WebPush subscription',
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionId = newUlid();
|
||||
|
|
@ -125,7 +126,9 @@ export class PushDispatcher {
|
|||
);
|
||||
if (existing.rowCount && existing.rows[0]) {
|
||||
if (existing.rows[0].employee_id !== parsedEmployeeId) {
|
||||
throw new PushConflictError('Subscription endpoint is already registered to another employee');
|
||||
throw new PushConflictError(
|
||||
'Subscription endpoint is already registered to another employee',
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE staff_push_subscriptions
|
||||
|
|
@ -156,7 +159,6 @@ export class PushDispatcher {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove an employee's WebPush subscription by endpoint.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
export interface VapidKeys {
|
||||
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
||||
|
|
@ -214,27 +215,40 @@ export function encryptPushPayload(payload: string | Buffer, keys: PushSubscript
|
|||
*/
|
||||
export function isPrivateOrLoopbackHost(host: string): boolean {
|
||||
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
||||
const ipv4Match = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (ipv4Match) {
|
||||
const parts = ipv4Match.slice(1).map(Number);
|
||||
if (
|
||||
h === 'localhost' ||
|
||||
h.endsWith('.localhost') ||
|
||||
h.endsWith('.local') ||
|
||||
h.endsWith('.internal')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const ipType = net.isIP(h);
|
||||
if (ipType === 4) {
|
||||
const parts = h.split('.').map(Number);
|
||||
const [o1, o2] = parts;
|
||||
if (o1 === 127 || o1 === 10 || o1 === 0) return true;
|
||||
if (o1 === 172 && o2! >= 16 && o2! <= 31) return true;
|
||||
if (o1 === 192 && o2! === 168) return true;
|
||||
if (o1 === 169 && o2! === 254) return true;
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
h === '::1' ||
|
||||
h === '::' ||
|
||||
h.startsWith('fc') ||
|
||||
h.startsWith('fd') ||
|
||||
h.startsWith('fe80') ||
|
||||
h.includes('::ffff:127.') ||
|
||||
h.includes('::ffff:10.') ||
|
||||
h.includes('::ffff:192.168.')
|
||||
) {
|
||||
return true;
|
||||
if (ipType === 6) {
|
||||
if (h === '::1' || h === '::') return true;
|
||||
if (
|
||||
h.startsWith('fc') ||
|
||||
h.startsWith('fd') ||
|
||||
h.startsWith('fe8') ||
|
||||
h.startsWith('fe9') ||
|
||||
h.startsWith('fea') ||
|
||||
h.startsWith('feb')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (h.startsWith('::ffff:')) {
|
||||
return isPrivateOrLoopbackHost(h.slice(7));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -283,7 +297,6 @@ export async function sendWebPush(
|
|||
redirect: 'manual',
|
||||
});
|
||||
|
||||
|
||||
const statusCode = response.status;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return { statusCode, successful: true, shouldRemoveSubscription: false };
|
||||
|
|
|
|||
|
|
@ -371,7 +371,8 @@ export class SyncService {
|
|||
|
||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
||||
// 1. If cursor is provided and NOT a snapshot cursor -> incremental pull from staff_sync_change_log
|
||||
const isSnapshotCursor = typeof options.cursor === 'string' && options.cursor.startsWith('snap_');
|
||||
const isSnapshotCursor =
|
||||
typeof options.cursor === 'string' && options.cursor.startsWith('snap_');
|
||||
if (options.cursor && !isSnapshotCursor) {
|
||||
const seq = decodeCursor(tenantId, options.cursor);
|
||||
const filterTypes =
|
||||
|
|
@ -850,7 +851,8 @@ export class SyncService {
|
|||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
||||
error_message: 'Время отметки находится вне допустимого диапазона (-180 дней .. +5 минут)',
|
||||
error_message:
|
||||
'Время отметки находится вне допустимого диапазона (-180 дней .. +5 минут)',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -870,7 +872,8 @@ export class SyncService {
|
|||
}
|
||||
|
||||
// Check org_unit_id: payload cannot override foreign device org_unit_id
|
||||
const payloadOrgUnitId = typeof payload.org_unit_id === 'string' ? payload.org_unit_id : null;
|
||||
const payloadOrgUnitId =
|
||||
typeof payload.org_unit_id === 'string' ? payload.org_unit_id : null;
|
||||
if (payloadOrgUnitId && device.org_unit_id && payloadOrgUnitId !== device.org_unit_id) {
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
|
|
|
|||
|
|
@ -672,8 +672,13 @@ export async function registerOperationsRoutes(
|
|||
const context = await auth.require(request, 'staff:payroll:calculate');
|
||||
|
||||
if (context.role === 'point_manager') {
|
||||
if (!request.body.org_unit_id || !(context.orgUnitIds ?? []).includes(request.body.org_unit_id)) {
|
||||
return reply.forbidden('Менеджер точки может рассчитывать зарплату только для своей точки.');
|
||||
if (
|
||||
!request.body.org_unit_id ||
|
||||
!(context.orgUnitIds ?? []).includes(request.body.org_unit_id)
|
||||
) {
|
||||
return reply.forbidden(
|
||||
'Менеджер точки может рассчитывать зарплату только для своей точки.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1096,7 +1101,9 @@ export async function registerOperationsRoutes(
|
|||
const delta = desiredAmount - previousAmount;
|
||||
if (delta === 0) continue;
|
||||
const isInitial = previousAmount === 0;
|
||||
const rowKey = isInitial ? entry.external_key : `${entry.external_key}:adj:${newUlid()}`;
|
||||
const rowKey = isInitial
|
||||
? entry.external_key
|
||||
: `${entry.external_key}:adj:${newUlid()}`;
|
||||
await client.query(
|
||||
`INSERT INTO staff_payroll_ledger
|
||||
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
||||
|
|
|
|||
182
src/portal.ts
182
src/portal.ts
|
|
@ -208,7 +208,6 @@ export async function registerPortalRoutes(
|
|||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
api.delete<{ Body: DeleteSubscriptionBody }>(
|
||||
|
|
@ -297,10 +296,7 @@ export async function registerPortalRoutes(
|
|||
const sessionId = createSession(request.body.access_token, sessionMaxAgeSeconds);
|
||||
reply
|
||||
.header('cache-control', 'no-store')
|
||||
.header(
|
||||
'set-cookie',
|
||||
serializeSessionCookie(sessionId, sessionMaxAgeSeconds),
|
||||
);
|
||||
.header('set-cookie', serializeSessionCookie(sessionId, sessionMaxAgeSeconds));
|
||||
return { role, expires_in: sessionMaxAgeSeconds };
|
||||
},
|
||||
);
|
||||
|
|
@ -315,7 +311,6 @@ export async function registerPortalRoutes(
|
|||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
|
||||
api.get('/portal', async (request, reply) => {
|
||||
reply.header('cache-control', 'no-store');
|
||||
const context = await resolveContext(identity, request);
|
||||
|
|
@ -394,7 +389,6 @@ export async function registerPortalRoutes(
|
|||
});
|
||||
});
|
||||
|
||||
|
||||
api.post<{ Body: ProfessionBody }>(
|
||||
'/professions',
|
||||
{
|
||||
|
|
@ -965,7 +959,6 @@ export async function registerPortalRoutes(
|
|||
|
||||
return reply.code(result.duplicate ? 200 : 201).send(responseBody);
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
api.post<{ Body: ReconcileSessionsBody }>(
|
||||
|
|
@ -1215,7 +1208,13 @@ export async function registerPortalRoutes(
|
|||
JOIN staff_payroll_batches pb ON pb.tenant_id = bl.tenant_id AND pb.id = bl.batch_id
|
||||
WHERE bl.tenant_id = $1 AND bl.work_session_id = $5 AND pb.status = 'signed'
|
||||
LIMIT 1`,
|
||||
[context.tenantId, current.employee_id, current.org_unit_id, current.business_date, sessionId],
|
||||
[
|
||||
context.tenantId,
|
||||
current.employee_id,
|
||||
current.org_unit_id,
|
||||
current.business_date,
|
||||
sessionId,
|
||||
],
|
||||
);
|
||||
if (lockedCheck.rowCount && lockedCheck.rowCount > 0) {
|
||||
return { kind: 'locked' as const };
|
||||
|
|
@ -1299,7 +1298,9 @@ export async function registerPortalRoutes(
|
|||
);
|
||||
if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.');
|
||||
if (outcome.kind === 'locked') {
|
||||
return reply.conflict('Смена входит в утверждённую ведомость или подписанный пакет и не может быть изменена.');
|
||||
return reply.conflict(
|
||||
'Смена входит в утверждённую ведомость или подписанный пакет и не может быть изменена.',
|
||||
);
|
||||
}
|
||||
if (outcome.kind === 'note_required') {
|
||||
return reply.badRequest('Для ручной корректировки смены укажите причину.');
|
||||
|
|
@ -1426,27 +1427,23 @@ export async function registerPortalRoutes(
|
|||
const tokenHint = `${rawToken.slice(0, 4)}...${rawToken.slice(-4)}`;
|
||||
const orgUnitIds = request.body.org_unit_ids ?? [];
|
||||
|
||||
await database.tenantTransaction(
|
||||
context.tenantId,
|
||||
context.actorId,
|
||||
async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_access_grants
|
||||
await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_access_grants
|
||||
(id, tenant_id, role, org_unit_ids, display_name, token_hash, token_hint, created_by)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)`,
|
||||
[
|
||||
grantId,
|
||||
context.tenantId,
|
||||
request.body.role,
|
||||
JSON.stringify(orgUnitIds),
|
||||
request.body.display_name.trim(),
|
||||
tokenHash,
|
||||
tokenHint,
|
||||
context.actorId,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
[
|
||||
grantId,
|
||||
context.tenantId,
|
||||
request.body.role,
|
||||
JSON.stringify(orgUnitIds),
|
||||
request.body.display_name.trim(),
|
||||
tokenHash,
|
||||
tokenHint,
|
||||
context.actorId,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return reply.code(201).send({
|
||||
id: grantId,
|
||||
|
|
@ -1487,12 +1484,9 @@ export async function registerPortalRoutes(
|
|||
const context = await requirePermission(identity, request, 'staff:employee:write');
|
||||
const employeeId = parseUlid(request.params.employeeId);
|
||||
|
||||
await database.tenantTransaction(
|
||||
context.tenantId,
|
||||
context.actorId,
|
||||
async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_biometric_consents
|
||||
await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_biometric_consents
|
||||
(tenant_id, employee_id, status, document_ref, updated_by, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (tenant_id, employee_id) DO UPDATE SET
|
||||
|
|
@ -1500,16 +1494,15 @@ export async function registerPortalRoutes(
|
|||
document_ref = EXCLUDED.document_ref,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()`,
|
||||
[
|
||||
context.tenantId,
|
||||
employeeId,
|
||||
request.body.status,
|
||||
request.body.document_ref ?? null,
|
||||
context.actorId,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
[
|
||||
context.tenantId,
|
||||
employeeId,
|
||||
request.body.status,
|
||||
request.body.document_ref ?? null,
|
||||
context.actorId,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return reply.send({ status: request.body.status });
|
||||
},
|
||||
|
|
@ -1599,7 +1592,10 @@ export async function registerPortalRoutes(
|
|||
if (!(context.orgUnitIds ?? []).includes(orgUnitId)) {
|
||||
return reply.forbidden('Менеджер точки может формировать пакет только для своей точки.');
|
||||
}
|
||||
} else if (context.role !== 'accountant' && !context.permissions.has('staff:payroll:calculate')) {
|
||||
} else if (
|
||||
context.role !== 'accountant' &&
|
||||
!context.permissions.has('staff:payroll:calculate')
|
||||
) {
|
||||
return reply.forbidden('Недостаточно прав для формирования зарплатного пакета.');
|
||||
}
|
||||
|
||||
|
|
@ -1629,7 +1625,14 @@ export async function registerPortalRoutes(
|
|||
`INSERT INTO staff_payroll_batches
|
||||
(id, tenant_id, org_unit_id, period_start, period_end, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, 'draft', $6)`,
|
||||
[batchId, context.tenantId, orgUnitId, request.body.from, request.body.to, context.actorId],
|
||||
[
|
||||
batchId,
|
||||
context.tenantId,
|
||||
orgUnitId,
|
||||
request.body.from,
|
||||
request.body.to,
|
||||
context.actorId,
|
||||
],
|
||||
);
|
||||
|
||||
const lines: Array<{
|
||||
|
|
@ -1658,7 +1661,7 @@ export async function registerPortalRoutes(
|
|||
const rawParamsRecord: Record<string, unknown> =
|
||||
typeof assignRes.rows[0]?.params === 'string'
|
||||
? (JSON.parse(assignRes.rows[0].params) as Record<string, unknown>)
|
||||
: (assignRes.rows[0]?.params as Record<string, unknown> | undefined) ?? {};
|
||||
: ((assignRes.rows[0]?.params as Record<string, unknown> | undefined) ?? {});
|
||||
let amount = 0;
|
||||
const shiftRate =
|
||||
typeof rawParamsRecord.shift_rate_minor === 'number'
|
||||
|
|
@ -1736,7 +1739,9 @@ export async function registerPortalRoutes(
|
|||
assertCookieMutationOrigin(request);
|
||||
const context = await resolveContext(identity, request);
|
||||
if (context.role !== 'accountant') {
|
||||
return reply.forbidden('Только бухгалтер может отправлять зарплатный пакет на согласование.');
|
||||
return reply.forbidden(
|
||||
'Только бухгалтер может отправлять зарплатный пакет на согласование.',
|
||||
);
|
||||
}
|
||||
const batchId = parseUlid(request.params.id);
|
||||
|
||||
|
|
@ -1784,32 +1789,29 @@ export async function registerPortalRoutes(
|
|||
},
|
||||
);
|
||||
|
||||
api.get(
|
||||
'/notifications',
|
||||
async (request, reply) => {
|
||||
const context = await resolveContext(identity, request);
|
||||
const rows = await database.tenantTransaction(
|
||||
context.tenantId,
|
||||
context.actorId,
|
||||
async (client) => {
|
||||
const res = await client.query<{
|
||||
type: string;
|
||||
subject_id: string;
|
||||
payload: unknown;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT type, subject_id, payload, created_at
|
||||
api.get('/notifications', async (request, reply) => {
|
||||
const context = await resolveContext(identity, request);
|
||||
const rows = await database.tenantTransaction(
|
||||
context.tenantId,
|
||||
context.actorId,
|
||||
async (client) => {
|
||||
const res = await client.query<{
|
||||
type: string;
|
||||
subject_id: string;
|
||||
payload: unknown;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT type, subject_id, payload, created_at
|
||||
FROM staff_batch_notifications
|
||||
WHERE tenant_id = $1 AND (recipient_actor_id = $2 OR recipient_role = $3)
|
||||
WHERE tenant_id = $1 AND (recipient_actor_id = $2 OR (recipient_actor_id IS NULL AND recipient_role = $3))
|
||||
ORDER BY created_at DESC`,
|
||||
[context.tenantId, context.actorId, context.role ?? ''],
|
||||
);
|
||||
return res.rows;
|
||||
},
|
||||
);
|
||||
return reply.send({ notifications: rows });
|
||||
},
|
||||
);
|
||||
[context.tenantId, context.actorId, context.role ?? ''],
|
||||
);
|
||||
return res.rows;
|
||||
},
|
||||
);
|
||||
return reply.send({ notifications: rows });
|
||||
});
|
||||
|
||||
api.post<{ Params: { id: string } }>(
|
||||
'/payroll/batches/:id/sign',
|
||||
|
|
@ -1895,16 +1897,34 @@ export async function registerPortalRoutes(
|
|||
await client.query(
|
||||
`INSERT INTO staff_payroll_ledger
|
||||
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code,
|
||||
ref_type, ref_id, correlation_id, description, created_by)
|
||||
ref_type, ref_id, correlation_id, note, created_by)
|
||||
VALUES ($1, $2, $3, $4, 'accrual', $5, 'RUB', 'payroll_run', $6, $7, 'Начисление по пакету', $8)`,
|
||||
[newUlid(), context.tenantId, line.employee_id, periodEndDate, amount, batchId, context.correlationId, context.actorId],
|
||||
[
|
||||
newUlid(),
|
||||
context.tenantId,
|
||||
line.employee_id,
|
||||
periodEndDate,
|
||||
amount,
|
||||
batchId,
|
||||
context.correlationId,
|
||||
context.actorId,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO staff_payroll_ledger
|
||||
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code,
|
||||
ref_type, ref_id, correlation_id, description, created_by)
|
||||
ref_type, ref_id, correlation_id, note, created_by)
|
||||
VALUES ($1, $2, $3, $4, 'payout', $5, 'RUB', 'payout', $6, $7, 'Выплата по пакету', $8)`,
|
||||
[newUlid(), context.tenantId, line.employee_id, periodEndDate, -amount, batchId, context.correlationId, context.actorId],
|
||||
[
|
||||
newUlid(),
|
||||
context.tenantId,
|
||||
line.employee_id,
|
||||
periodEndDate,
|
||||
-amount,
|
||||
batchId,
|
||||
context.correlationId,
|
||||
context.actorId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2077,7 +2097,7 @@ function withStandaloneEmployeeAccess(
|
|||
config: AppConfig,
|
||||
database: Database,
|
||||
): IdentityPort {
|
||||
if (config.STAFF_RUNTIME_MODE !== 'standalone' || !config.STANDALONE_MANAGER_TOKEN) {
|
||||
if (config.STAFF_RUNTIME_MODE !== 'standalone') {
|
||||
return baseIdentity;
|
||||
}
|
||||
return {
|
||||
|
|
@ -2105,10 +2125,7 @@ function withStandaloneEmployeeAccess(
|
|||
});
|
||||
|
||||
if (grant) {
|
||||
const permissions = new Set<string>([
|
||||
'staff:health:read',
|
||||
'staff:notification:read',
|
||||
]);
|
||||
const permissions = new Set<string>(['staff:health:read', 'staff:notification:read']);
|
||||
const rawOrgUnitIds = grant.org_unit_ids;
|
||||
const orgUnitIds: string[] = Array.isArray(rawOrgUnitIds)
|
||||
? (rawOrgUnitIds as string[])
|
||||
|
|
@ -2257,7 +2274,6 @@ function assertCookieMutationOrigin(request: FastifyRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
async function requirePermission(
|
||||
identity: IdentityPort,
|
||||
request: FastifyRequest,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export async function registerSyncRoutes(
|
|||
function assertCookieMutationOrigin(request: FastifyRequest) {
|
||||
const authorization = request.headers.authorization;
|
||||
if (authorization?.startsWith('Bearer ')) return;
|
||||
const cookie = request.headers.cookie;
|
||||
if (!cookie || !cookie.includes('__Host-krona_staff_session=')) return;
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) {
|
||||
throw request.server.httpErrors.forbidden('Запрос без Origin отклонён.');
|
||||
|
|
@ -106,7 +108,9 @@ export async function registerSyncRoutes(
|
|||
}
|
||||
|
||||
if (!token) {
|
||||
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
|
||||
throw request.server.httpErrors.unauthorized(
|
||||
'Требуется действующий ключ доступа руководителя',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,51 +1,4 @@
|
|||
{
|
||||
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md. Ключи — двойные ID «владелец/мой» (docs/audit/2026-09-26/AUDIT.md и TASK-GROK-2 §2), для сценариев без ID владельца — мой ID.",
|
||||
"defects": {
|
||||
"K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
|
||||
"K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
|
||||
"K12/N3": "SSRF через push-endpoint на внутренние адреса",
|
||||
"K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
|
||||
"N9": "перепривязка чужого push-endpoint",
|
||||
"N10": "справочники доступны терминалу",
|
||||
"N11": "импорт с неверным kind → 500",
|
||||
"N12": "pay-assignment без additionalProperties:false и верхних границ",
|
||||
"K1/F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму",
|
||||
"K10/F3": "cookie = сырой токен, logout не отзывает",
|
||||
"K11/F4": "нет rate limit на вход",
|
||||
"F7": "Idempotency-Key не используется",
|
||||
"F10": "CSRF: мутации по cookie без Origin разрешены",
|
||||
"K13/SYNC-A": "устройство само подтверждает смену через upsert_work_session",
|
||||
"K13/SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry",
|
||||
"SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)",
|
||||
"S5/SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)",
|
||||
"S5/SYNC-D": "изменения из портала не пишутся в staff_sync_change_log",
|
||||
"S4/SYNC-E": "отметки уволенных сотрудников принимаются через sync",
|
||||
"S4/SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку",
|
||||
"S3/SYNC-G": "одна битая команда роняет весь пакет (500)",
|
||||
"S8/SYNC-H": "регистрация устройства по cookie без проверки Origin",
|
||||
"K1/SYNC-I": "без токенов анонимно регистрируется устройство",
|
||||
"S2/SYNC-J": "sync принимает отметки вне окна времени",
|
||||
"SYNC-K": "ack принимает курсор больше выданного сервером",
|
||||
"K1/PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)",
|
||||
"K2/PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development",
|
||||
"D6/OWN-01": "решение владельца: роли точки (этап 4 Codex)",
|
||||
"D6/OWN-02": "решение владельца: пакет отправляет только бухгалтер",
|
||||
"OWN-03": "решение владельца: уведомления директорам",
|
||||
"OWN-04": "решение владельца: подписывает только директор",
|
||||
"OWN-05": "решение владельца: ledger пуст до подписи",
|
||||
"OWN-06": "решение владельца: подпись → ledger, неизменяемость",
|
||||
"K19/OWN-07": "решение владельца: изменение смены после отправки возвращает пакет",
|
||||
"OWN-08": "решение владельца: возврат с комментарием",
|
||||
"OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки",
|
||||
"K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
|
||||
"K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала",
|
||||
"K6/D5": "отклонение смены из подписанной ведомости: 200 без сторно (сейчас блокирован K3 на подтверждении)",
|
||||
"K7/D2": "повторный импорт книги теряет суммы: две точки в один день → 222,00 вместо 333,00 ₽",
|
||||
"K8/D1": "подписанная ведомость не воспроизводится после смены ставки; контракта verify нет (C4), сейчас блокирован K3",
|
||||
"K9/D3": "параметры пресета официанта (shift_rate_minor и др.) не сохраняются",
|
||||
"S9": "удаление точки с устройством: 23502 null value in column tenant_id (ON DELETE SET NULL на составном FK)",
|
||||
"A4": "ответы 500 содержат SQLSTATE и текст PostgreSQL",
|
||||
"N12-DATE": "month=2026-13 → 500 (22008) вместо 400",
|
||||
"PILOT-REHOME": "нет scripts/rehome-tenant.mjs (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 на настоящий tenant не реализован"
|
||||
}
|
||||
"defects": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Database } from '../../src/infrastructure/database.js';
|
||||
import { newUlid, parseUlid } from '../../src/core/ids.js';
|
||||
import { newUlid } from '../../src/core/ids.js';
|
||||
import { SyncService } from '../../src/domain/sync/service.js';
|
||||
import { buildApp } from '../../src/app.js';
|
||||
import { loadConfig } from '../../src/config.js';
|
||||
|
|
@ -26,8 +26,8 @@ const cleanupTables = [
|
|||
|
||||
describeDatabase('Sync Bootstrap SYNC-C0 (Live PostgreSQL)', () => {
|
||||
const database = new Database(connectionString!);
|
||||
const tenantId = parseUlid('01J0000000000000000000000A');
|
||||
const actorId = parseUlid('01J00000000000000000000001');
|
||||
const tenantId = newUlid();
|
||||
const actorId = newUlid();
|
||||
const orgUnitId = newUlid();
|
||||
const employeeId = newUlid();
|
||||
const deviceId = newUlid();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Database } from '../../src/infrastructure/database.js';
|
||||
import { newUlid, parseUlid } from '../../src/core/ids.js';
|
||||
import { newUlid } from '../../src/core/ids.js';
|
||||
import { SyncService } from '../../src/domain/sync/service.js';
|
||||
import type { SyncCommand } from '../../src/domain/sync/types.js';
|
||||
|
||||
|
|
@ -21,8 +21,8 @@ const cleanupTables = [
|
|||
|
||||
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
|
||||
const database = new Database(connectionString!);
|
||||
const tenantId = parseUlid('01J0000000000000000000000A');
|
||||
const actorId = parseUlid('01J00000000000000000000001');
|
||||
const tenantId = newUlid();
|
||||
const actorId = newUlid();
|
||||
const deviceId = newUlid();
|
||||
const employeeId = newUlid();
|
||||
const sessionId = newUlid();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Database } from '../../src/infrastructure/database.js';
|
||||
import { newUlid, parseUlid } from '../../src/core/ids.js';
|
||||
import { newUlid } from '../../src/core/ids.js';
|
||||
|
||||
const connectionString = process.env.DATABASE_TEST_URL;
|
||||
const describeDatabase = connectionString ? describe : describe.skip;
|
||||
|
|
@ -30,10 +30,10 @@ const cleanupTables = [
|
|||
|
||||
describeDatabase('PostgreSQL tenant isolation', () => {
|
||||
const database = new Database(connectionString!);
|
||||
const tenantA = parseUlid('01J0000000000000000000000A');
|
||||
const tenantB = parseUlid('01J0000000000000000000000B');
|
||||
const actorA = parseUlid('01J0000000000000000000001A');
|
||||
const actorB = parseUlid('01J0000000000000000000001B');
|
||||
const tenantA = newUlid();
|
||||
const tenantB = newUlid();
|
||||
const actorA = newUlid();
|
||||
const actorB = newUlid();
|
||||
const eventId = newUlid();
|
||||
const professionId = newUlid();
|
||||
const employeeIdA = newUlid();
|
||||
|
|
@ -352,7 +352,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
});
|
||||
|
||||
const consentVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT status FROM staff_biometric_consents WHERE employee_id = $1', [employeeIdA]),
|
||||
client.query('SELECT status FROM staff_biometric_consents WHERE employee_id = $1', [
|
||||
employeeIdA,
|
||||
]),
|
||||
);
|
||||
expect(consentVisibleToB.rowCount).toBe(0);
|
||||
|
||||
|
|
@ -367,4 +369,3 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue