fix: resolve production readiness blockers, tenant isolation and e2e defects

This commit is contained in:
KAgent 2026-09-30 18:16:05 +07:00
parent 89621ea069
commit e6dd4b743c
17 changed files with 350 additions and 222 deletions

View file

@ -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
View 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
View file

@ -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": [

View file

@ -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)

View file

@ -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 {

View file

@ -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);

View file

@ -9,4 +9,3 @@ export interface RequestContext {
role?: string; role?: string;
orgUnitIds?: readonly string[]; orgUnitIds?: readonly string[];
} }

View file

@ -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.
*/ */

View file

@ -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 };

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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 {

View file

@ -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 не реализован"
}
} }

View file

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

View file

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

View file

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