From e6dd4b743c7bd47552dd29bf566cb6cb7cedb3f1 Mon Sep 17 00:00:00 2001 From: KAgent Date: Wed, 30 Sep 2026 18:16:05 +0700 Subject: [PATCH] fix: resolve production readiness blockers, tenant isolation and e2e defects --- README.md | 2 +- docs/RUN-MANUAL-TESTING.md | 156 ++++++++++++++++++ package-lock.json | 60 ++----- scripts/rehome-tenant.mjs | 5 +- src/adapters/identity.ts | 2 + src/app.ts | 5 + src/core/context.ts | 1 - src/domain/notification/push-dispatcher.ts | 10 +- src/domain/notification/web-push.ts | 45 +++-- src/domain/sync/service.ts | 9 +- src/operations.ts | 13 +- src/portal.ts | 182 +++++++++++---------- src/sync.ts | 6 +- tests/e2e/known-defects.json | 49 +----- tests/integration/sync-bootstrap.test.ts | 6 +- tests/integration/sync-rejection.test.ts | 6 +- tests/integration/tenant-isolation.test.ts | 15 +- 17 files changed, 350 insertions(+), 222 deletions(-) create mode 100644 docs/RUN-MANUAL-TESTING.md diff --git a/README.md b/README.md index dac7e8b..c0d7ea5 100644 --- a/README.md +++ b/README.md @@ -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). ## Расчётный контур diff --git a/docs/RUN-MANUAL-TESTING.md b/docs/RUN-MANUAL-TESTING.md new file mode 100644 index 0000000..186bdcd --- /dev/null +++ b/docs/RUN-MANUAL-TESTING.md @@ -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`
`GET /api/v1/staff/ready`
`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`
Headers: `Authorization: Bearer `
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`
Body: привязка правила (почасовая ставка 350 руб/час = 35000 minor units). | HTTP 201 Created. Версионированное правило сохранено в `staff_pay_assignments`. | [ ] | +| **9** | **Отметка прихода через терминал** | `POST /api/v1/staff/attendance/events`
Headers: `Authorization: Bearer `
Body: `{"event_id": "", "employee_id": "", "event_type": "check_in", "occurred_at": ""}` | 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`
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`
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 --target ` | Утилита производит корректное сравнение количества строк по всем таблицам (`Number`) без сбоев типов и ошибок внешних ключей. | [ ] | + +--- + +## 4. Диагностика и устранение неполадок + +1. **Ошибка `401 Unauthorized` при запросе от терминала**: + - Проверьте, передан ли заголовок `Authorization: Bearer ` или токен зарегистрированного устройства. +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 + ``` diff --git a/package-lock.json b/package-lock.json index fc4e7a7..f41259f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ diff --git a/scripts/rehome-tenant.mjs b/scripts/rehome-tenant.mjs index fadf6c5..3eb39c5 100644 --- a/scripts/rehome-tenant.mjs +++ b/scripts/rehome-tenant.mjs @@ -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) diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index f27f83f..d3e3c4c 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -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 { diff --git a/src/app.ts b/src/app.ts index 8935240..0f7b93c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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); diff --git a/src/core/context.ts b/src/core/context.ts index afa9c67..b1a8c69 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -9,4 +9,3 @@ export interface RequestContext { role?: string; orgUnitIds?: readonly string[]; } - diff --git a/src/domain/notification/push-dispatcher.ts b/src/domain/notification/push-dispatcher.ts index 4548d2d..d4efa56 100644 --- a/src/domain/notification/push-dispatcher.ts +++ b/src/domain/notification/push-dispatcher.ts @@ -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. */ diff --git a/src/domain/notification/web-push.ts b/src/domain/notification/web-push.ts index ae8d29f..20c6807 100644 --- a/src/domain/notification/web-push.ts +++ b/src/domain/notification/web-push.ts @@ -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 }; diff --git a/src/domain/sync/service.ts b/src/domain/sync/service.ts index deeeb14..338c14d 100644 --- a/src/domain/sync/service.ts +++ b/src/domain/sync/service.ts @@ -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, diff --git a/src/operations.ts b/src/operations.ts index 4f1533f..60a8fb7 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -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, diff --git a/src/portal.ts b/src/portal.ts index 1a85a0d..4906466 100644 --- a/src/portal.ts +++ b/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 = typeof assignRes.rows[0]?.params === 'string' ? (JSON.parse(assignRes.rows[0].params) as Record) - : (assignRes.rows[0]?.params as Record | undefined) ?? {}; + : ((assignRes.rows[0]?.params as Record | 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([ - 'staff:health:read', - 'staff:notification:read', - ]); + const permissions = new Set(['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, diff --git a/src/sync.ts b/src/sync.ts index 78d4756..6f24187 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -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 { diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json index 7fc1c9d..d3d86d1 100644 --- a/tests/e2e/known-defects.json +++ b/tests/e2e/known-defects.json @@ -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": {} } diff --git a/tests/integration/sync-bootstrap.test.ts b/tests/integration/sync-bootstrap.test.ts index c54007c..1e3194a 100644 --- a/tests/integration/sync-bootstrap.test.ts +++ b/tests/integration/sync-bootstrap.test.ts @@ -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(); diff --git a/tests/integration/sync-rejection.test.ts b/tests/integration/sync-rejection.test.ts index 85c883d..d86f2e2 100644 --- a/tests/integration/sync-rejection.test.ts +++ b/tests/integration/sync-rejection.test.ts @@ -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(); diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts index fe2d578..1e2d8cd 100644 --- a/tests/integration/tenant-isolation.test.ts +++ b/tests/integration/tenant-isolation.test.ts @@ -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(); }); }); -