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 db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`.
|
||||||
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
|
- `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"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1095,9 +1092,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1116,9 +1110,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1137,9 +1128,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1158,9 +1146,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1179,9 +1164,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1507,9 +1489,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -1691,9 +1673,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv/node_modules/fast-uri": {
|
"node_modules/ajv/node_modules/fast-uri": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -1776,9 +1758,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2265,9 +2247,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "4.1.4",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
|
||||||
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
|
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2662,9 +2644,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2900,9 +2882,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2925,9 +2904,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2950,9 +2926,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2975,9 +2948,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,6 @@ export function parseTenantRehomeArgs(args) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function validateBackupEvidenceObject(evidence) {
|
export function validateBackupEvidenceObject(evidence) {
|
||||||
const backup = evidence?.backup;
|
const backup = evidence?.backup;
|
||||||
const restore = evidence?.restoreDrill;
|
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`,
|
`SELECT count(*)::text AS row_count FROM ${qualified('public', table)} WHERE tenant_id = $1`,
|
||||||
[tenantId],
|
[tenantId],
|
||||||
);
|
);
|
||||||
counts[table] = rows[0].row_count;
|
counts[table] = Number(rows[0].row_count);
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +353,6 @@ export async function rehomeTenant(client, options) {
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const foreignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
const foreignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
||||||
const ledgerBefore = await ledgerTotals(client, source);
|
const ledgerBefore = await ledgerTotals(client, source);
|
||||||
for (const fk of foreignKeys) {
|
for (const fk of foreignKeys) {
|
||||||
|
|
@ -522,7 +520,6 @@ async function main() {
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code)
|
typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ export class StandaloneIdentityAdapter implements IdentityPort {
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid standalone access token');
|
throw new Error('Invalid standalone access token');
|
||||||
}
|
}
|
||||||
|
} else if (access === 'manager') {
|
||||||
|
throw new Error('Standalone access tokens are not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ export async function buildApp({
|
||||||
const supplied = request.headers['x-correlation-id']?.toString();
|
const supplied = request.headers['x-correlation-id']?.toString();
|
||||||
return supplied && ulidPattern.test(supplied) ? supplied : newUlid();
|
return supplied && ulidPattern.test(supplied) ? supplied : newUlid();
|
||||||
},
|
},
|
||||||
|
ajv: {
|
||||||
|
customOptions: {
|
||||||
|
removeAdditional: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,3 @@ export interface RequestContext {
|
||||||
role?: string;
|
role?: string;
|
||||||
orgUnitIds?: readonly string[];
|
orgUnitIds?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ export class PushConflictError extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PushDispatcherOptions {
|
export interface PushDispatcherOptions {
|
||||||
|
|
||||||
database: Database;
|
database: Database;
|
||||||
vapidKeys: VapidKeys;
|
vapidKeys: VapidKeys;
|
||||||
systemActorId: string;
|
systemActorId: string;
|
||||||
|
|
@ -107,7 +106,9 @@ export class PushDispatcher {
|
||||||
const userAgent = (input.user_agent ?? '').trim();
|
const userAgent = (input.user_agent ?? '').trim();
|
||||||
|
|
||||||
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
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();
|
const subscriptionId = newUlid();
|
||||||
|
|
@ -125,7 +126,9 @@ export class PushDispatcher {
|
||||||
);
|
);
|
||||||
if (existing.rowCount && existing.rows[0]) {
|
if (existing.rowCount && existing.rows[0]) {
|
||||||
if (existing.rows[0].employee_id !== parsedEmployeeId) {
|
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(
|
await client.query(
|
||||||
`UPDATE staff_push_subscriptions
|
`UPDATE staff_push_subscriptions
|
||||||
|
|
@ -156,7 +159,6 @@ export class PushDispatcher {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an employee's WebPush subscription by endpoint.
|
* Remove an employee's WebPush subscription by endpoint.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
export interface VapidKeys {
|
export interface VapidKeys {
|
||||||
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
||||||
|
|
@ -214,28 +215,41 @@ export function encryptPushPayload(payload: string | Buffer, keys: PushSubscript
|
||||||
*/
|
*/
|
||||||
export function isPrivateOrLoopbackHost(host: string): boolean {
|
export function isPrivateOrLoopbackHost(host: string): boolean {
|
||||||
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
if (
|
||||||
const ipv4Match = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
h === 'localhost' ||
|
||||||
if (ipv4Match) {
|
h.endsWith('.localhost') ||
|
||||||
const parts = ipv4Match.slice(1).map(Number);
|
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;
|
const [o1, o2] = parts;
|
||||||
if (o1 === 127 || o1 === 10 || o1 === 0) return true;
|
if (o1 === 127 || o1 === 10 || o1 === 0) return true;
|
||||||
if (o1 === 172 && o2! >= 16 && o2! <= 31) return true;
|
if (o1 === 172 && o2! >= 16 && o2! <= 31) return true;
|
||||||
if (o1 === 192 && o2! === 168) return true;
|
if (o1 === 192 && o2! === 168) return true;
|
||||||
if (o1 === 169 && o2! === 254) return true;
|
if (o1 === 169 && o2! === 254) return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
if (ipType === 6) {
|
||||||
|
if (h === '::1' || h === '::') return true;
|
||||||
if (
|
if (
|
||||||
h === '::1' ||
|
|
||||||
h === '::' ||
|
|
||||||
h.startsWith('fc') ||
|
h.startsWith('fc') ||
|
||||||
h.startsWith('fd') ||
|
h.startsWith('fd') ||
|
||||||
h.startsWith('fe80') ||
|
h.startsWith('fe8') ||
|
||||||
h.includes('::ffff:127.') ||
|
h.startsWith('fe9') ||
|
||||||
h.includes('::ffff:10.') ||
|
h.startsWith('fea') ||
|
||||||
h.includes('::ffff:192.168.')
|
h.startsWith('feb')
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (h.startsWith('::ffff:')) {
|
||||||
|
return isPrivateOrLoopbackHost(h.slice(7));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,7 +297,6 @@ export async function sendWebPush(
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
if (statusCode >= 200 && statusCode < 300) {
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
return { statusCode, successful: true, shouldRemoveSubscription: false };
|
return { statusCode, successful: true, shouldRemoveSubscription: false };
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,8 @@ export class SyncService {
|
||||||
|
|
||||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
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
|
// 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) {
|
if (options.cursor && !isSnapshotCursor) {
|
||||||
const seq = decodeCursor(tenantId, options.cursor);
|
const seq = decodeCursor(tenantId, options.cursor);
|
||||||
const filterTypes =
|
const filterTypes =
|
||||||
|
|
@ -850,7 +851,8 @@ export class SyncService {
|
||||||
command_type: cmd.command_type,
|
command_type: cmd.command_type,
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
||||||
error_message: 'Время отметки находится вне допустимого диапазона (-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
|
// 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) {
|
if (payloadOrgUnitId && device.org_unit_id && payloadOrgUnitId !== device.org_unit_id) {
|
||||||
return {
|
return {
|
||||||
idempotency_key: cmd.idempotency_key,
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
|
|
||||||
|
|
@ -672,8 +672,13 @@ export async function registerOperationsRoutes(
|
||||||
const context = await auth.require(request, 'staff:payroll:calculate');
|
const context = await auth.require(request, 'staff:payroll:calculate');
|
||||||
|
|
||||||
if (context.role === 'point_manager') {
|
if (context.role === 'point_manager') {
|
||||||
if (!request.body.org_unit_id || !(context.orgUnitIds ?? []).includes(request.body.org_unit_id)) {
|
if (
|
||||||
return reply.forbidden('Менеджер точки может рассчитывать зарплату только для своей точки.');
|
!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;
|
const delta = desiredAmount - previousAmount;
|
||||||
if (delta === 0) continue;
|
if (delta === 0) continue;
|
||||||
const isInitial = previousAmount === 0;
|
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(
|
await client.query(
|
||||||
`INSERT INTO staff_payroll_ledger
|
`INSERT INTO staff_payroll_ledger
|
||||||
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
||||||
|
|
|
||||||
100
src/portal.ts
100
src/portal.ts
|
|
@ -208,7 +208,6 @@ export async function registerPortalRoutes(
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
api.delete<{ Body: DeleteSubscriptionBody }>(
|
api.delete<{ Body: DeleteSubscriptionBody }>(
|
||||||
|
|
@ -297,10 +296,7 @@ export async function registerPortalRoutes(
|
||||||
const sessionId = createSession(request.body.access_token, sessionMaxAgeSeconds);
|
const sessionId = createSession(request.body.access_token, sessionMaxAgeSeconds);
|
||||||
reply
|
reply
|
||||||
.header('cache-control', 'no-store')
|
.header('cache-control', 'no-store')
|
||||||
.header(
|
.header('set-cookie', serializeSessionCookie(sessionId, sessionMaxAgeSeconds));
|
||||||
'set-cookie',
|
|
||||||
serializeSessionCookie(sessionId, sessionMaxAgeSeconds),
|
|
||||||
);
|
|
||||||
return { role, expires_in: sessionMaxAgeSeconds };
|
return { role, expires_in: sessionMaxAgeSeconds };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -315,7 +311,6 @@ export async function registerPortalRoutes(
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
api.get('/portal', async (request, reply) => {
|
api.get('/portal', async (request, reply) => {
|
||||||
reply.header('cache-control', 'no-store');
|
reply.header('cache-control', 'no-store');
|
||||||
const context = await resolveContext(identity, request);
|
const context = await resolveContext(identity, request);
|
||||||
|
|
@ -394,7 +389,6 @@ export async function registerPortalRoutes(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
api.post<{ Body: ProfessionBody }>(
|
api.post<{ Body: ProfessionBody }>(
|
||||||
'/professions',
|
'/professions',
|
||||||
{
|
{
|
||||||
|
|
@ -965,7 +959,6 @@ export async function registerPortalRoutes(
|
||||||
|
|
||||||
return reply.code(result.duplicate ? 200 : 201).send(responseBody);
|
return reply.code(result.duplicate ? 200 : 201).send(responseBody);
|
||||||
},
|
},
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
api.post<{ Body: ReconcileSessionsBody }>(
|
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
|
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'
|
WHERE bl.tenant_id = $1 AND bl.work_session_id = $5 AND pb.status = 'signed'
|
||||||
LIMIT 1`,
|
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) {
|
if (lockedCheck.rowCount && lockedCheck.rowCount > 0) {
|
||||||
return { kind: 'locked' as const };
|
return { kind: 'locked' as const };
|
||||||
|
|
@ -1299,7 +1298,9 @@ export async function registerPortalRoutes(
|
||||||
);
|
);
|
||||||
if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.');
|
if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.');
|
||||||
if (outcome.kind === 'locked') {
|
if (outcome.kind === 'locked') {
|
||||||
return reply.conflict('Смена входит в утверждённую ведомость или подписанный пакет и не может быть изменена.');
|
return reply.conflict(
|
||||||
|
'Смена входит в утверждённую ведомость или подписанный пакет и не может быть изменена.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (outcome.kind === 'note_required') {
|
if (outcome.kind === 'note_required') {
|
||||||
return reply.badRequest('Для ручной корректировки смены укажите причину.');
|
return reply.badRequest('Для ручной корректировки смены укажите причину.');
|
||||||
|
|
@ -1426,10 +1427,7 @@ export async function registerPortalRoutes(
|
||||||
const tokenHint = `${rawToken.slice(0, 4)}...${rawToken.slice(-4)}`;
|
const tokenHint = `${rawToken.slice(0, 4)}...${rawToken.slice(-4)}`;
|
||||||
const orgUnitIds = request.body.org_unit_ids ?? [];
|
const orgUnitIds = request.body.org_unit_ids ?? [];
|
||||||
|
|
||||||
await database.tenantTransaction(
|
await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||||
context.tenantId,
|
|
||||||
context.actorId,
|
|
||||||
async (client) => {
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_access_grants
|
`INSERT INTO staff_access_grants
|
||||||
(id, tenant_id, role, org_unit_ids, display_name, token_hash, token_hint, created_by)
|
(id, tenant_id, role, org_unit_ids, display_name, token_hash, token_hint, created_by)
|
||||||
|
|
@ -1445,8 +1443,7 @@ export async function registerPortalRoutes(
|
||||||
context.actorId,
|
context.actorId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
id: grantId,
|
id: grantId,
|
||||||
|
|
@ -1487,10 +1484,7 @@ export async function registerPortalRoutes(
|
||||||
const context = await requirePermission(identity, request, 'staff:employee:write');
|
const context = await requirePermission(identity, request, 'staff:employee:write');
|
||||||
const employeeId = parseUlid(request.params.employeeId);
|
const employeeId = parseUlid(request.params.employeeId);
|
||||||
|
|
||||||
await database.tenantTransaction(
|
await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||||
context.tenantId,
|
|
||||||
context.actorId,
|
|
||||||
async (client) => {
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_biometric_consents
|
`INSERT INTO staff_biometric_consents
|
||||||
(tenant_id, employee_id, status, document_ref, updated_by, updated_at)
|
(tenant_id, employee_id, status, document_ref, updated_by, updated_at)
|
||||||
|
|
@ -1508,8 +1502,7 @@ export async function registerPortalRoutes(
|
||||||
context.actorId,
|
context.actorId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return reply.send({ status: request.body.status });
|
return reply.send({ status: request.body.status });
|
||||||
},
|
},
|
||||||
|
|
@ -1599,7 +1592,10 @@ export async function registerPortalRoutes(
|
||||||
if (!(context.orgUnitIds ?? []).includes(orgUnitId)) {
|
if (!(context.orgUnitIds ?? []).includes(orgUnitId)) {
|
||||||
return reply.forbidden('Менеджер точки может формировать пакет только для своей точки.');
|
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('Недостаточно прав для формирования зарплатного пакета.');
|
return reply.forbidden('Недостаточно прав для формирования зарплатного пакета.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1629,7 +1625,14 @@ export async function registerPortalRoutes(
|
||||||
`INSERT INTO staff_payroll_batches
|
`INSERT INTO staff_payroll_batches
|
||||||
(id, tenant_id, org_unit_id, period_start, period_end, status, created_by)
|
(id, tenant_id, org_unit_id, period_start, period_end, status, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'draft', $6)`,
|
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<{
|
const lines: Array<{
|
||||||
|
|
@ -1658,7 +1661,7 @@ export async function registerPortalRoutes(
|
||||||
const rawParamsRecord: Record<string, unknown> =
|
const rawParamsRecord: Record<string, unknown> =
|
||||||
typeof assignRes.rows[0]?.params === 'string'
|
typeof assignRes.rows[0]?.params === 'string'
|
||||||
? (JSON.parse(assignRes.rows[0].params) as Record<string, unknown>)
|
? (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;
|
let amount = 0;
|
||||||
const shiftRate =
|
const shiftRate =
|
||||||
typeof rawParamsRecord.shift_rate_minor === 'number'
|
typeof rawParamsRecord.shift_rate_minor === 'number'
|
||||||
|
|
@ -1736,7 +1739,9 @@ export async function registerPortalRoutes(
|
||||||
assertCookieMutationOrigin(request);
|
assertCookieMutationOrigin(request);
|
||||||
const context = await resolveContext(identity, request);
|
const context = await resolveContext(identity, request);
|
||||||
if (context.role !== 'accountant') {
|
if (context.role !== 'accountant') {
|
||||||
return reply.forbidden('Только бухгалтер может отправлять зарплатный пакет на согласование.');
|
return reply.forbidden(
|
||||||
|
'Только бухгалтер может отправлять зарплатный пакет на согласование.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const batchId = parseUlid(request.params.id);
|
const batchId = parseUlid(request.params.id);
|
||||||
|
|
||||||
|
|
@ -1784,9 +1789,7 @@ export async function registerPortalRoutes(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
api.get(
|
api.get('/notifications', async (request, reply) => {
|
||||||
'/notifications',
|
|
||||||
async (request, reply) => {
|
|
||||||
const context = await resolveContext(identity, request);
|
const context = await resolveContext(identity, request);
|
||||||
const rows = await database.tenantTransaction(
|
const rows = await database.tenantTransaction(
|
||||||
context.tenantId,
|
context.tenantId,
|
||||||
|
|
@ -1800,7 +1803,7 @@ export async function registerPortalRoutes(
|
||||||
}>(
|
}>(
|
||||||
`SELECT type, subject_id, payload, created_at
|
`SELECT type, subject_id, payload, created_at
|
||||||
FROM staff_batch_notifications
|
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`,
|
ORDER BY created_at DESC`,
|
||||||
[context.tenantId, context.actorId, context.role ?? ''],
|
[context.tenantId, context.actorId, context.role ?? ''],
|
||||||
);
|
);
|
||||||
|
|
@ -1808,8 +1811,7 @@ export async function registerPortalRoutes(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return reply.send({ notifications: rows });
|
return reply.send({ notifications: rows });
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
api.post<{ Params: { id: string } }>(
|
api.post<{ Params: { id: string } }>(
|
||||||
'/payroll/batches/:id/sign',
|
'/payroll/batches/:id/sign',
|
||||||
|
|
@ -1895,16 +1897,34 @@ export async function registerPortalRoutes(
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_payroll_ledger
|
`INSERT INTO staff_payroll_ledger
|
||||||
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code,
|
(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)`,
|
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(
|
await client.query(
|
||||||
`INSERT INTO staff_payroll_ledger
|
`INSERT INTO staff_payroll_ledger
|
||||||
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code,
|
(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)`,
|
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,
|
config: AppConfig,
|
||||||
database: Database,
|
database: Database,
|
||||||
): IdentityPort {
|
): IdentityPort {
|
||||||
if (config.STAFF_RUNTIME_MODE !== 'standalone' || !config.STANDALONE_MANAGER_TOKEN) {
|
if (config.STAFF_RUNTIME_MODE !== 'standalone') {
|
||||||
return baseIdentity;
|
return baseIdentity;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -2105,10 +2125,7 @@ function withStandaloneEmployeeAccess(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (grant) {
|
if (grant) {
|
||||||
const permissions = new Set<string>([
|
const permissions = new Set<string>(['staff:health:read', 'staff:notification:read']);
|
||||||
'staff:health:read',
|
|
||||||
'staff:notification:read',
|
|
||||||
]);
|
|
||||||
const rawOrgUnitIds = grant.org_unit_ids;
|
const rawOrgUnitIds = grant.org_unit_ids;
|
||||||
const orgUnitIds: string[] = Array.isArray(rawOrgUnitIds)
|
const orgUnitIds: string[] = Array.isArray(rawOrgUnitIds)
|
||||||
? (rawOrgUnitIds as string[])
|
? (rawOrgUnitIds as string[])
|
||||||
|
|
@ -2257,7 +2274,6 @@ function assertCookieMutationOrigin(request: FastifyRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function requirePermission(
|
async function requirePermission(
|
||||||
identity: IdentityPort,
|
identity: IdentityPort,
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ export async function registerSyncRoutes(
|
||||||
function assertCookieMutationOrigin(request: FastifyRequest) {
|
function assertCookieMutationOrigin(request: FastifyRequest) {
|
||||||
const authorization = request.headers.authorization;
|
const authorization = request.headers.authorization;
|
||||||
if (authorization?.startsWith('Bearer ')) return;
|
if (authorization?.startsWith('Bearer ')) return;
|
||||||
|
const cookie = request.headers.cookie;
|
||||||
|
if (!cookie || !cookie.includes('__Host-krona_staff_session=')) return;
|
||||||
const origin = request.headers.origin;
|
const origin = request.headers.origin;
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
throw request.server.httpErrors.forbidden('Запрос без Origin отклонён.');
|
throw request.server.httpErrors.forbidden('Запрос без Origin отклонён.');
|
||||||
|
|
@ -106,7 +108,9 @@ export async function registerSyncRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
|
throw request.server.httpErrors.unauthorized(
|
||||||
|
'Требуется действующий ключ доступа руководителя',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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.",
|
"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": {
|
"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 не реализован"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { Database } from '../../src/infrastructure/database.js';
|
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 { SyncService } from '../../src/domain/sync/service.js';
|
||||||
import { buildApp } from '../../src/app.js';
|
import { buildApp } from '../../src/app.js';
|
||||||
import { loadConfig } from '../../src/config.js';
|
import { loadConfig } from '../../src/config.js';
|
||||||
|
|
@ -26,8 +26,8 @@ const cleanupTables = [
|
||||||
|
|
||||||
describeDatabase('Sync Bootstrap SYNC-C0 (Live PostgreSQL)', () => {
|
describeDatabase('Sync Bootstrap SYNC-C0 (Live PostgreSQL)', () => {
|
||||||
const database = new Database(connectionString!);
|
const database = new Database(connectionString!);
|
||||||
const tenantId = parseUlid('01J0000000000000000000000A');
|
const tenantId = newUlid();
|
||||||
const actorId = parseUlid('01J00000000000000000000001');
|
const actorId = newUlid();
|
||||||
const orgUnitId = newUlid();
|
const orgUnitId = newUlid();
|
||||||
const employeeId = newUlid();
|
const employeeId = newUlid();
|
||||||
const deviceId = newUlid();
|
const deviceId = newUlid();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { Database } from '../../src/infrastructure/database.js';
|
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 { SyncService } from '../../src/domain/sync/service.js';
|
||||||
import type { SyncCommand } from '../../src/domain/sync/types.js';
|
import type { SyncCommand } from '../../src/domain/sync/types.js';
|
||||||
|
|
||||||
|
|
@ -21,8 +21,8 @@ const cleanupTables = [
|
||||||
|
|
||||||
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
|
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
|
||||||
const database = new Database(connectionString!);
|
const database = new Database(connectionString!);
|
||||||
const tenantId = parseUlid('01J0000000000000000000000A');
|
const tenantId = newUlid();
|
||||||
const actorId = parseUlid('01J00000000000000000000001');
|
const actorId = newUlid();
|
||||||
const deviceId = newUlid();
|
const deviceId = newUlid();
|
||||||
const employeeId = newUlid();
|
const employeeId = newUlid();
|
||||||
const sessionId = newUlid();
|
const sessionId = newUlid();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { Database } from '../../src/infrastructure/database.js';
|
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 connectionString = process.env.DATABASE_TEST_URL;
|
||||||
const describeDatabase = connectionString ? describe : describe.skip;
|
const describeDatabase = connectionString ? describe : describe.skip;
|
||||||
|
|
@ -30,10 +30,10 @@ const cleanupTables = [
|
||||||
|
|
||||||
describeDatabase('PostgreSQL tenant isolation', () => {
|
describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
const database = new Database(connectionString!);
|
const database = new Database(connectionString!);
|
||||||
const tenantA = parseUlid('01J0000000000000000000000A');
|
const tenantA = newUlid();
|
||||||
const tenantB = parseUlid('01J0000000000000000000000B');
|
const tenantB = newUlid();
|
||||||
const actorA = parseUlid('01J0000000000000000000001A');
|
const actorA = newUlid();
|
||||||
const actorB = parseUlid('01J0000000000000000000001B');
|
const actorB = newUlid();
|
||||||
const eventId = newUlid();
|
const eventId = newUlid();
|
||||||
const professionId = newUlid();
|
const professionId = newUlid();
|
||||||
const employeeIdA = newUlid();
|
const employeeIdA = newUlid();
|
||||||
|
|
@ -352,7 +352,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const consentVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
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);
|
expect(consentVisibleToB.rowCount).toBe(0);
|
||||||
|
|
||||||
|
|
@ -367,4 +369,3 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue