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();
});
});
-