Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a85cf3d32b | |||
| 50b789b69f | |||
| c84f8c4373 | |||
| 705f35623c | |||
| 888f737c88 | |||
| 17ff7f8f04 | |||
| 251bf83aac | |||
| 0e31783036 | |||
| b888eb440a | |||
| 89c217a34f | |||
| 64bbe4f7c2 | |||
| 5745917efe | |||
| 564d984f2a | |||
| fdff36c553 | |||
| 3711a92958 | |||
| 6e36c2455d | |||
| 4c2f4da664 | |||
| 1df353ae51 | |||
| 47cf202226 | |||
| 888ead3264 | |||
| dcc1040f73 | |||
| b873c53aad | |||
| bf4ed65d0e | |||
| 3b2096b4cb | |||
| 2f4cf433cd | |||
| 5fef4647c1 | |||
| 815f0a2dcd | |||
| e6752b5e4c | |||
| 1220bddf3e |
@@ -38,7 +38,5 @@ See `references/aggregation-template.md`.
|
||||
|
||||
## Behavioral rule reminders
|
||||
|
||||
- **«Не использован ≠ проблема» (условное, Pravila §16.4 v1.36)** — when reporting node usage counts, distinguish two cases:
|
||||
1. **Unused + no profile task in episodes** → capability-readiness, do NOT flag.
|
||||
2. **Unused + profile task present (missed activation)** → mandatory section in the report. Cite `tools/observer-classification-map.json` for the classification→node mapping and `tools/.node-dormancy.json` for DEFERRED exclusions. NEVER mark unused-by-design nodes as «zombie» / «removal candidate».
|
||||
- **«Не использован ≠ проблема»** — when reporting node usage counts, NEVER mark unused nodes as «zombie» / «removal candidate». Cite `memory/feedback_brain_unused_tools_not_problem.md`.
|
||||
- **No auto-edit** — every regulatory suggestion is a candidate, not an action.
|
||||
|
||||
@@ -55,32 +55,6 @@ For each factor below, render a table: factor value × outcome counts
|
||||
|
||||
(one table each — same columns)
|
||||
|
||||
## Missed Activations (Pravila §16.4 v1.36)
|
||||
|
||||
Surface candidates where a profile-classified task ran with `node_chosen === 'direct'` and at least one non-dormant recommended node was available. The analyzer returns `missedActivations: { totalMissed, byNode, byClassification }` — render the two breakdowns below.
|
||||
|
||||
**Source:** `analyze(episodes, { classificationMap, dormancy }).missedActivations`.
|
||||
|
||||
### By node
|
||||
|
||||
| Node | Episodes missed | Classifications hit |
|
||||
|---|---|---|
|
||||
| #NN | N | refactor (a), bugfix (b) |
|
||||
|
||||
### By classification
|
||||
|
||||
| Classification | Missed episodes | Top recommended nodes (non-dormant) |
|
||||
|---|---|---|
|
||||
| refactor | N | #11, #12, #43 |
|
||||
|
||||
**Interpretation guide:**
|
||||
|
||||
- High count on one node → router-miss pattern. Suggest updating `tools/observer-classification-map.json` or a workflow nudge.
|
||||
- Spread across many nodes with classification leaning to `other` → the classification dictionary may need refinement (separate concern, not a missed activation).
|
||||
- All zero → either no profile work this period, or the router is operating cleanly.
|
||||
|
||||
**NOT to be auto-applied:** these are candidates for human review in retro, not commits or hook blocks.
|
||||
|
||||
## Episodes → tasks (from analyzer `tasks`)
|
||||
|
||||
| task_ref | episodes | turns that are rework |
|
||||
@@ -139,4 +113,4 @@ problem** per `memory/feedback_brain_unused_tools_not_problem`.
|
||||
## Informational metrics (NOT alerts)
|
||||
|
||||
- Nodes used at least once this period: K / 60+
|
||||
- Nodes never used since beginning of observer logs: L / 67 — **not a problem if there was no profile task** per Pravila §16.4 v1.36 and [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md). See `## Missed Activations` above for profile-task-present cases.
|
||||
- Nodes never used since beginning of observer logs: L / 60+ — **not a problem** per [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
name: pdn-152fz-audit
|
||||
description: Аудит защиты персональных данных Лидерры и соответствие 152-ФЗ. Режим 1 — техника (где лежат ПДн в схеме/коде, RLS, маскирование pg_anonymizer, утечки в логах/Sentry/CSV-экспортах, шифрование). Режим 2 — закон (хранение в РФ, согласия, сроки/удаление, реестр обработки, уведомление РКН, права субъекта pd_subject_request). Используй при «проверь ПДн», «утекают ли персональные данные», «соответствие 152-ФЗ», «где хранятся телефоны лидов», «маскируются ли данные в дампах». НЕ для денежной корректности (billing-audit), security-аудита кода (D3/Semgrep), юридического оформления договоров/политик (D2 право), generic-угроз (threat-model #72).
|
||||
---
|
||||
|
||||
# ПДн 152-ФЗ Аудит — защита персональных данных Лидерры
|
||||
|
||||
Проектный скил раздела A8 карты «Информационная безопасность». Проверяет
|
||||
**защиту персональных данных** и соответствие Федеральному закону №152-ФЗ
|
||||
«О персональных данных» для SaaS-портала, обрабатывающего телефоны лидов
|
||||
и данные клиентов-компаний перед выходом в продакшен.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- Вопрос «не утекают ли ПДн в логи / Sentry / CSV-экспорты?»
|
||||
- Проверка технической защиты ПДн перед запуском (RLS, маскирование, шифрование).
|
||||
- Оценка соответствия 152-ФЗ: хранение в РФ, согласия, права субъекта, реестр.
|
||||
- Ревью кода, затрагивающего `deals`, `users`, `pd_subject_requests`,
|
||||
`pd_processing_log`, `supplier_leads` или CSV-импорт/экспорт лидов.
|
||||
|
||||
## Два режима
|
||||
|
||||
### Режим 1 — Технический аудит ПДн
|
||||
|
||||
Проверяет, что персональные данные физически защищены в коде и схеме БД.
|
||||
|
||||
Вопросы:
|
||||
|
||||
- Какие таблицы/колонки содержат ПДн? Под RLS ли они?
|
||||
- Маскируются ли ПДн в дампах (pg_anonymizer)?
|
||||
- Не утекают ли phone/email/ФИО в Laravel-логи, Sentry, `activity_log.context`,
|
||||
`auth_log`, `supplier_leads.raw_payload`?
|
||||
- Зашифрованы ли чувствительные поля в покое (totp_secret)?
|
||||
- Защищены ли CSV-экспорты лидов (signed URL + аудит в `pd_processing_log`)?
|
||||
|
||||
**Запустить:** пройти по чек-листу `references/checklist.md` → Раздел 1.
|
||||
|
||||
### Режим 2 — Соответствие 152-ФЗ
|
||||
|
||||
Проверяет правовую и процессную сторону обработки ПДн.
|
||||
|
||||
Вопросы:
|
||||
|
||||
- Хранятся ли ПДн на территории РФ?
|
||||
- Зафиксированы ли согласия субъектов ПДн (`tenant_consents`)?
|
||||
- Есть ли механизм обращений субъектов (`pd_subject_requests` + дедлайн 30 дней)?
|
||||
- Ведётся ли журнал обработки ПДн (`pd_processing_log`)?
|
||||
- Уведомлен ли РКН? Есть ли реестр обработки?
|
||||
- Реализовано ли право на ограничение обработки (`processing_restricted`)?
|
||||
|
||||
**Запустить:** пройти по чек-листу `references/checklist.md` → Раздел 2.
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `billing-audit` #62 — тот про *денежную корректность начислений*; pdn-152fz-audit про *персональные данные*.
|
||||
- ≠ D3 «audit-security» (#39/#40 Trail of Bits / Semgrep) — те про *security-уязвимости кода*; pdn-152fz-audit про *данные субъектов ПДн*.
|
||||
- ≠ D2 «Право / договоры» — там юридическое оформление (политика обработки, договор с оператором); pdn-152fz-audit про *технику и процедуры*.
|
||||
- ≠ `threat-model` #72 — тот про *моделирование угроз*; pdn-152fz-audit про *конкретные ПДн в конкретных таблицах*.
|
||||
|
||||
## Связано
|
||||
|
||||
- Reuse: Boost #10 (SQL-запросы к схеме), Semgrep #25 (статанализ кода на утечки),
|
||||
Sentry MCP #34 (проверка runtime-маскирования), pg_anonymizer #29 (дампы).
|
||||
- ADR-013 (infosec-tooling A8).
|
||||
- Нормативная основа: ФЗ-152 ст.18 (уведомление РКН), ст.21 ч.5 (ограничение
|
||||
обработки), ст.22 (реестр операторов), ст.14 (права субъекта).
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"skill": "pdn-152fz-audit",
|
||||
"cases": [
|
||||
{"prompt": "проверь, не утекают ли телефоны лидов в логи", "should_trigger": true},
|
||||
{"prompt": "соответствует ли портал 152-ФЗ перед запуском", "should_trigger": true},
|
||||
{"prompt": "проверь, не теряются ли копейки в списании", "should_trigger": false, "expected": "billing-audit"},
|
||||
{"prompt": "смоделируй угрозы при выходе портала в интернет", "should_trigger": false, "expected": "threat-model"},
|
||||
{"prompt": "составь договор обработки персональных данных", "should_trigger": false, "expected": "D2 право"}
|
||||
]
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
# ПДн 152-ФЗ — чек-лист аудита Лидерры
|
||||
|
||||
Основан на реальных артефактах проекта (db/schema.sql v8.26, 21.05.2026).
|
||||
|
||||
## Таблицы-носители ПДн (инвентарь)
|
||||
|
||||
| Таблица | ПДн-колонки | Тип субъекта |
|
||||
|---|---|---|
|
||||
| `deals` | `phone`, `phones` (JSONB), `contact_name`, `city` | лид (физлицо) |
|
||||
| `supplier_leads` | `phone`, `raw_payload` (JSONB — весь payload поставщика) | лид (физлицо) |
|
||||
| `users` | `email`, `first_name`, `last_name`, `phone`, `totp_secret` | пользователь-клиент |
|
||||
| `tenants` | `contact_email`, `organization_name` | организация-клиент |
|
||||
| `auth_log` | `email` (при login_failed для неизвестного пользователя) | пользователь |
|
||||
| `pd_subject_requests` | `subject_email`, `subject_phone`, `subject_full_name` | субъект ПДн |
|
||||
| `impersonation_tokens` | косвенно (связь user — admin) | пользователь |
|
||||
| `import_log` | `filename`, `file_path` (может содержать имя файла с ПДн) | лид (косвенно) |
|
||||
|
||||
---
|
||||
|
||||
## Раздел 1 — Технический аудит ПДн
|
||||
|
||||
### Т1. RLS на таблицах-носителях ПДн
|
||||
|
||||
- [ ] `deals` — `ENABLE ROW LEVEL SECURITY` ✅ (подтверждено schema.sql:2780).
|
||||
Проверить: `FORCE ROW LEVEL SECURITY` не выставлен (только у `lead_charges`
|
||||
— там сильнее). Убедиться, что `crm_app_user` не BYPASSRLS.
|
||||
- [ ] `users` — RLS включён (schema.sql:2778). Политика `tenant_isolation` по
|
||||
`tenant_id`. Проверить: нет прямого SELECT * без `SET LOCAL app.current_tenant_id`.
|
||||
- [ ] `supplier_leads` — **RLS не включён** (таблица SaaS-уровня, schema.sql:1948).
|
||||
Это осознанное решение. Проверить: доступ только из воркера
|
||||
(`crm_supplier_worker` BYPASSRLS) с явным `WHERE tenant_id`.
|
||||
- [ ] `pd_subject_requests` — **RLS не включён** намеренно (saas-уровневая,
|
||||
schema.sql:2483). Доступ только через `crm_admin_user` BYPASSRLS.
|
||||
Проверить: tenant-приложение к таблице не обращается.
|
||||
- [ ] `auth_log` — RLS включён (schema.sql:2810). Политика `tenant_isolation`.
|
||||
Проверить: поле `email` в строке `login_failed` — не утекает ли email
|
||||
несуществующего пользователя в посторонний тенант.
|
||||
- [ ] `import_log` — RLS включён (schema.sql:2790).
|
||||
|
||||
### Т2. Маскирование ПДн в дампах (pg_anonymizer #29)
|
||||
|
||||
- [ ] **Проверить вручную:** OPEN-И-24 (schema.sql:113) — «pg_anonymizer процедура,
|
||||
документация в Прил. И, без изменений схемы». Расширение ставится в фазе 3
|
||||
(db/CHANGELOG_schema.md:625). На момент аудита — **расширение может быть не
|
||||
установлено**. Выполнить: `psql -c "SELECT extname FROM pg_extension WHERE extname='anon';"`.
|
||||
- [ ] Если pg_anonymizer установлен: проверить наличие `SECURITY LABEL` /
|
||||
`anon.mask_column` на колонках `deals.phone`, `deals.contact_name`,
|
||||
`users.email`, `users.first_name`, `users.last_name`.
|
||||
- [ ] Если pg_anonymizer **не установлен**: дампы (`pg_dump`) содержат ПДн в открытом
|
||||
виде — критический риск перед продакшеном. Требуется: либо установить
|
||||
расширение и настроить маски, либо запретить дампы с ПДн вне зашифрованного
|
||||
хранилища.
|
||||
|
||||
### Т3. Утечки ПДн в логи и Sentry
|
||||
|
||||
- [ ] **Sentry PII-scrubbing** (OPEN-И-16, schema.sql:68): конфигурация в
|
||||
`app/config/sentry.php` (narrative §22 «Sentry PII-scrubbing»).
|
||||
Проверить: whitelist событий задан; regex-маска `phone`/`email`/`password`/
|
||||
`secret`/`token`/`api_key` включена. Тест: намеренно вызвать ошибку с
|
||||
телефоном в payload и проверить Sentry-событие.
|
||||
- [ ] **Laravel-логи (`storage/logs/`)**: нет ли `Log::info`/`Log::debug` с
|
||||
`$deal->phone`, `$lead->phone`, `request()->all()` в необработанном виде.
|
||||
Grep: `Log::` + `phone\|email\|contact_name` в `app/app/`.
|
||||
- [ ] **`activity_log.context`** (JSONB, schema.sql:1775): поле `context` журнала
|
||||
действий по сделкам. Проверить: не пишется ли туда `phone`/`contact_name`
|
||||
полностью (должны быть только ID и маскированные значения).
|
||||
- [ ] **`supplier_leads.raw_payload`** (JSONB, schema.sql:1966): хранит весь
|
||||
webhook-payload от поставщика, включая телефон. Это осознанное хранение
|
||||
(нужно для дебага/реконсайла). Проверить: доступ ограничен только
|
||||
`crm_supplier_worker` + `crm_admin_user`; не отдаётся в tenant API.
|
||||
- [ ] **`auth_log.email`** (schema.sql:1458): email попадает в лог при `login_failed`
|
||||
для неизвестного адреса. Проверить: колонка не индексируется publicly,
|
||||
доступна только под RLS tenant-политикой.
|
||||
|
||||
### Т4. Шифрование чувствительных полей в покое
|
||||
|
||||
- [ ] **`users.totp_secret`** (schema.sql:723): комментарий «ШИФРУЕТСЯ `Crypt::encrypt`».
|
||||
Проверить: в коде Laravel используется `Crypt::encrypt`/`decrypt`, не plain TEXT.
|
||||
Grep: `totp_secret` в моделях/сервисах — нет ли прямого assignment без encrypt.
|
||||
- [ ] **`tenants.webhook_token`** (schema.sql:628): хранится в открытом виде как
|
||||
уникальный токен. Допустимо (по дизайну — это API-ключ, не пароль), но
|
||||
проверить: не логируется ли при ротации (`webhook_token_rotated_at`).
|
||||
- [ ] **Encryption at rest (диск/облако)**: Yandex Cloud `ru-central1` — проверить,
|
||||
включено ли шифрование диска/объектного хранилища на уровне YC-консоли.
|
||||
Это вне кода, но обязательно для 152-ФЗ.
|
||||
|
||||
### Т5. CSV-экспорт лидов и signed URL
|
||||
|
||||
- [ ] **`report_jobs`** (schema.sql:2313): `file_path` = `s3://bucket/path/file.xlsx`.
|
||||
Триггер `trg_report_jobs_export_log` (schema.sql:3096) автоматически пишет
|
||||
запись в `pd_processing_log` при INSERT. Проверить: триггер активен в prod.
|
||||
SQL: `SELECT tgname, tgenabled FROM pg_trigger WHERE tgname = 'trg_report_jobs_export_log';`
|
||||
- [ ] **Signed URL TTL**: schema.sql:3182 — «доступ через signed URL TTL 1 ч».
|
||||
Проверить в коде: `Storage::temporaryUrl(...)` с `now()->addHour()`.
|
||||
Файлы экспорта не доступны без аутентификации.
|
||||
- [ ] **`report_jobs.expires_at`**: автоудаление файла. Проверить: есть ли
|
||||
scheduled command / cleanup job, удаляющий S3-файл и обнуляющий `file_path`
|
||||
после `expires_at`.
|
||||
|
||||
### Т6. CSV-импорт исторических лидов
|
||||
|
||||
- [ ] **`import_log.file_path`** (schema.sql:1544): путь к загруженному CSV-файлу с
|
||||
ПДн. Проверить: файл хранится во временном/приватном location, не в
|
||||
публично доступном URL; удаляется после обработки.
|
||||
- [ ] **Проверить вручную:** содержит ли исторический CSV телефоны лидов в открытом
|
||||
виде в `storage/`? Если да — нужен cleanup после импорта.
|
||||
|
||||
---
|
||||
|
||||
## Раздел 2 — Соответствие 152-ФЗ
|
||||
|
||||
### З1. Хранение ПДн на территории РФ (ст.18.1 152-ФЗ)
|
||||
|
||||
- [ ] Облако: Yandex Cloud, регион `ru-central1` (Москва) — **✅ РФ**.
|
||||
Подтверждено в CLAUDE.md §2.
|
||||
- [ ] S3-хранилище файлов экспорта (`report_jobs.file_path`): убедиться, что
|
||||
Yandex Object Storage используется (не AWS S3 / GCS). Проверить
|
||||
`app/config/filesystems.php`.
|
||||
- [ ] Self-hosted Sentry: Yandex Cloud `ru-central1` — ✅ РФ (CLAUDE.md §2).
|
||||
Проверить: Sentry не проксирует события в eu.sentry.io / sentry.io (US).
|
||||
- [ ] Unisender Go (email): **Проверить вручную** — уточнить у Unisender
|
||||
расположение серверов; письма с ПДн (email адреса) передаются провайдеру.
|
||||
|
||||
### З2. Согласия субъектов ПДн (ст.6, ст.9 152-ФЗ)
|
||||
|
||||
- [ ] **`tenant_consents`** (schema.sql:2430): таблица согласий. Проверить:
|
||||
при регистрации тенанта записывается `consent_type='pd_processing'` с
|
||||
`document_version`, `ip_address`, `user_agent`, `given_at`.
|
||||
- [ ] Проверить: согласие на обработку ПДн лидов (телефоны физлиц) — не пользователя-
|
||||
клиента, а лидов. Лиды приходят от поставщика (crm.bp-gr.ru) — проверить
|
||||
договор с поставщиком (правовое основание обработки ст.6 ч.1 п.5 или п.4).
|
||||
**Проверить вручную** — вне schema (юридический документ).
|
||||
- [ ] `consent_type` значения: `pd_processing`, `marketing`, `oferta_v1` — убедиться,
|
||||
что consent_type='pd_processing' обязателен при регистрации (нет bypass).
|
||||
|
||||
### З3. Сроки хранения и удаление (ст.21 152-ФЗ)
|
||||
|
||||
- [ ] **Soft-delete в `deals`** (schema.sql:1648 `deleted_at`): после soft-delete
|
||||
данные остаются. Проверить: есть ли политика retention (hard-delete или
|
||||
анонимизация `phone`/`contact_name` через N дней после `deleted_at`).
|
||||
**Проверить вручную:** scheduled command для hard-delete сделок.
|
||||
- [ ] **`users.deleted_at`** (schema.sql:751): комментарий «soft delete + анонимизация».
|
||||
Проверить в коде: при soft-delete пользователя анонимизируются ли
|
||||
`email`/`first_name`/`last_name`/`phone`? Grep: `UserObserver` / `UserService`
|
||||
метод delete/anonymize.
|
||||
- [ ] **Право на удаление** (ст.21): обращение типа `request_type='deletion'` в
|
||||
`pd_subject_requests`. Проверить: есть ли процедура исполнения (скрипт/ручной
|
||||
процесс) удаления ПДн конкретного субъекта по `subject_phone`/`subject_email`
|
||||
из `deals`, `supplier_leads`, `activity_log`.
|
||||
|
||||
### З4. Журнал обработки ПДн (ст.18.1 152-ФЗ)
|
||||
|
||||
- [ ] **`pd_processing_log`** (schema.sql:2449): таблица журнала. RLS включён
|
||||
(schema.sql:2806), политика `tenant_isolation` (schema.sql:2846).
|
||||
Проверить: `subject_type`, `action`, `purpose` заполняются при
|
||||
ключевых операциях (просмотр сделки, экспорт, удаление).
|
||||
- [ ] **Триггер экспорта** `trg_report_jobs_export_log` (schema.sql:3096): AFTER
|
||||
INSERT на `report_jobs` → INSERT `pd_processing_log` с `action='exported'`.
|
||||
Закрывает требование ст.18 (учёт трансграничной передачи / выгрузки).
|
||||
- [ ] **Append-only hash chain** (schema.sql:63): `log_hash BYTEA` + триггеры
|
||||
`BEFORE UPDATE/DELETE` с `RAISE EXCEPTION`. Проверить: цепочка целостна.
|
||||
SQL: `SELECT id, log_hash IS NULL AS broken FROM pd_processing_log ORDER BY id DESC LIMIT 10;`
|
||||
|
||||
### З5. Обращения субъектов ПДн (ст.14 152-ФЗ)
|
||||
|
||||
- [ ] **`pd_subject_requests`** (schema.sql:2491): таблица обращений. Поля:
|
||||
`subject_email`, `subject_phone`, `subject_full_name`, `request_type`
|
||||
(`access`/`rectification`/`deletion`/`objection`), `deadline_at` (30 дней),
|
||||
`processing_restricted`.
|
||||
- [ ] **Триггер дедлайна** `trg_pd_subject_requests_deadline` (schema.sql:3165):
|
||||
функция `set_pd_subject_request_deadline()` заполняет `deadline_at =
|
||||
received_at + INTERVAL '30 days'` при INSERT/UPDATE.
|
||||
Проверить: `SELECT COUNT(*) FROM pd_subject_requests WHERE deadline_at IS NULL;`
|
||||
— должно быть 0.
|
||||
- [ ] **`processing_restricted`** (schema.sql:2514, ст.21 ч.5): при `TRUE`
|
||||
`ProcessingRestrictedException` блокирует операции с ПДн субъекта.
|
||||
Проверить в коде: `ProcessingRestrictionGuard` вызывается в сервисах
|
||||
перед mutable-операциями с `deals`/`users`.
|
||||
- [ ] Индекс (schema.sql:2519): `idx_pd_requests_restricted` — эффективный поиск
|
||||
активных ограничений. Проверить: он используется в `ProcessingRestrictionGuard`.
|
||||
|
||||
### З6. Уведомление РКН и реестр обработки (ст.22 152-ФЗ)
|
||||
|
||||
- [ ] **Проверить вручную:** подана ли заявка оператора в реестр Роскомнадзора
|
||||
на сайте pd.rkn.gov.ru? Это организационная мера, вне кода.
|
||||
- [ ] **Проверить вручную:** составлен ли внутренний реестр обработки ПДн
|
||||
(перечень категорий субъектов, целей, сроков, мер защиты)?
|
||||
Требование ст.22.1 ФЗ-152.
|
||||
- [ ] **`incidents_log`** (schema.sql:2535): при утечке ПДн — поле
|
||||
`related_pd_subject_request_ids BIGINT[]`. Проверить: есть ли внутренняя
|
||||
процедура уведомления РКН в течение 24 ч (ст.21.1, с 01.03.2023)?
|
||||
|
||||
### З7. Передача ПДн третьим лицам
|
||||
|
||||
- [ ] **Поставщик crm.bp-gr.ru**: получает запросы с телефонами лидов обратно
|
||||
при синхронизации статусов (`supplier_sync_log`). Проверить наличие договора
|
||||
на обработку ПДн по поручению (ст.6 ч.3 152-ФЗ).
|
||||
**Проверить вручную** — юридический документ.
|
||||
- [ ] **Unisender Go** (email-рассылки с именами пользователей):
|
||||
**Проверить вручную** — договор поручения на обработку ПДн.
|
||||
- [ ] **JivoSite** (helpdesk): передаются ли туда email/ФИО клиентов?
|
||||
**Проверить вручную**.
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: security-go-live
|
||||
description: Единый go-live security-gate Лидерры перед публикацией в интернете — один воспроизводимый прогон всех проверок безопасности и вердикт «можно/нельзя в прод». Оркеструет ZAP (#68), Nuclei (#69), Ward (#70), pdn-152fz-audit (#71), threat-model (#72) + Semgrep #25 / Trivy #26 / gitleaks #8 / Trail of Bits #39. Используй при «прогон безопасности перед релизом», «можно ли выкатывать», «go-live security check», «финальная проверка безопасности». НЕ для полного 14-фазного аудита портала (audit-portal), отдельной проверки ПДн (pdn-152fz-audit #71) или угроз (threat-model #72).
|
||||
---
|
||||
|
||||
# Security Go-Live — единый gate безопасности перед публикацией
|
||||
|
||||
Проектный скил раздела A8 карты «Информационная безопасность». Запускает
|
||||
**один воспроизводимый прогон всех security-проверок** и выдаёт вердикт
|
||||
**GO / NO-GO** перед тем, как портал Лидерры становится доступным из интернета.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- «Прогони все проверки безопасности перед релизом»
|
||||
- «Можно ли выкатывать портал в прод по безопасности?»
|
||||
- «Go-live security check» / «финальная проверка безопасности»
|
||||
- «Готов ли портал к публикации со стороны ИБ?»
|
||||
|
||||
## Что это и чем НЕ является
|
||||
|
||||
**Это:** операционный gate — воспроизводимый чек-лист, который прогоняется
|
||||
каждый раз перед go-live и выдаёт конкретный вердикт с перечнем блокеров.
|
||||
|
||||
**Это НЕ:**
|
||||
|
||||
- ≠ `audit-portal` — тот 14-фазный сквозной аудит качества всего портала
|
||||
(статанализ, тесты, схема БД, UI-smoke, a11y, coverage, bundle и пр.);
|
||||
security-go-live — security-only срез, занимает часть дня, не несколько дней.
|
||||
- ≠ `pdn-152fz-audit` #71 — тот глубокий аудит персональных данных и 152-ФЗ;
|
||||
security-go-live вызывает его как один шаг, не заменяет.
|
||||
- ≠ `threat-model` #72 — тот строит модель угроз как документ (STRIDE, карта
|
||||
точек входа); security-go-live проверяет, что выявленные угрозы ЗАКРЫТЫ.
|
||||
|
||||
## Порядок прогона
|
||||
|
||||
Полная процедура — `references/gate.md`. Кратко:
|
||||
|
||||
1. **Статика** — gitleaks, Semgrep, Ward (config/env/deps/code), Trail of Bits.
|
||||
2. **ПДн / 152-ФЗ** — вызвать `pdn-152fz-audit` #71.
|
||||
3. **Угрозы** — вызвать `threat-model` #72, убедиться что топ-угрозы закрыты.
|
||||
4. **Динамика (локальная цель по умолчанию)** — Nuclei (`bin/nuclei.exe`),
|
||||
затем ZAP (spider + active scan). Боевой сервер — только по явной команде.
|
||||
5. **Вердикт** — GO / NO-GO с явным списком блокеров.
|
||||
|
||||
## Выход
|
||||
|
||||
```
|
||||
=== SECURITY GO-LIVE REPORT ===
|
||||
Дата: YYYY-MM-DD
|
||||
Версия схемы: <schema-version>
|
||||
Commit: <HEAD>
|
||||
|
||||
[ШАГИ 1-4 — результаты по каждому инструменту]
|
||||
|
||||
=== ВЕРДИКТ: GO ✅ / NO-GO ❌ ===
|
||||
Блокеры (critical/high): <список или "нет">
|
||||
Предупреждения (medium): <список или "нет">
|
||||
=== END ===
|
||||
```
|
||||
|
||||
## Связано
|
||||
|
||||
- `references/gate.md` — подробная процедура прогона + формат вердикта.
|
||||
- `pdn-152fz-audit` #71, `threat-model` #72 — вызываются как подшаги.
|
||||
- ZAP #68 (OWASP, DAST), Nuclei #69 (CLI `bin/nuclei.exe`), Ward #70 (Go CLI).
|
||||
- gitleaks #8, Semgrep #25, Trivy #26, Trail of Bits #39 — статика.
|
||||
- ADR-013 (infosec-tooling A8), `docs/security/nuclei-setup.md`,
|
||||
`docs/security/infosec-vet.md`.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"skill": "security-go-live",
|
||||
"cases": [
|
||||
{"prompt": "прогони все проверки безопасности перед релизом", "should_trigger": true},
|
||||
{"prompt": "можно ли выкатывать портал в прод по безопасности", "should_trigger": true},
|
||||
{"prompt": "проведи полный аудит портала", "should_trigger": false, "expected": "audit-portal"},
|
||||
{"prompt": "проверь только персональные данные", "should_trigger": false, "expected": "pdn-152fz-audit"},
|
||||
{"prompt": "смоделируй угрозы", "should_trigger": false, "expected": "threat-model"}
|
||||
]
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
# Security Go-Live Gate — процедура прогона и формат вердикта
|
||||
|
||||
Подробная пошаговая процедура для скила `security-go-live` (#73).
|
||||
Цель — один воспроизводимый прогон перед каждым выходом портала в интернет.
|
||||
|
||||
---
|
||||
|
||||
## Гарды
|
||||
|
||||
**IS8 — цель по умолчанию локальная.** Все динамические проверки (Nuclei, ZAP)
|
||||
направляются на локальную или тестовую копию портала (`127.0.0.1`). Боевой
|
||||
(`crm.bp-gr.ru` или любой публичный IP) — только по явной команде заказчика:
|
||||
«сканируй прод» / «сканируй боевой».
|
||||
|
||||
**IS7 — граница с `audit-portal`.** `security-go-live` — security-only gate:
|
||||
выдаёт GO/NO-GO по безопасности. Он не заменяет 14-фазный `audit-portal`
|
||||
(тесты, схема, UI-smoke, a11y, coverage, bundle и пр.). Перед первым
|
||||
production-деплоем рекомендуется прогнать `audit-portal` **и** `security-go-live`
|
||||
как два отдельных прогона; при плановых go-live (хотфикс/фича) — достаточно
|
||||
`security-go-live`.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1 — Статика (static analysis)
|
||||
|
||||
Запустить последовательно. Каждый инструмент фиксирует результат в разделе
|
||||
отчёта.
|
||||
|
||||
### 1.1 gitleaks — поиск секретов в истории
|
||||
|
||||
```powershell
|
||||
# Полная история
|
||||
.\bin\gitleaks.exe detect --source . --log-opts "--all"
|
||||
# Только staged/unstaged (перед коммитом)
|
||||
.\bin\gitleaks.exe protect --staged
|
||||
```
|
||||
|
||||
Ожидаемо: **0 утечек**. Любой leak = NO-GO (critical).
|
||||
|
||||
### 1.2 Semgrep — статический анализ кода
|
||||
|
||||
```powershell
|
||||
npm run sast
|
||||
```
|
||||
|
||||
Ожидаемо: **0 critical/high**. Medium — предупреждение (не блокер).
|
||||
|
||||
### 1.3 Ward — Laravel config / env / deps / code
|
||||
|
||||
Ward (#70) — Go-бинарь, замена заброшенного Enlightn. Сканирует:
|
||||
`.env` (8 проверок), `config/*.php` (13 проверок), зависимости Composer
|
||||
(через OSV.dev), код (секреты, injection, XSS, debug-артефакты, crypto,
|
||||
CORS/CSRF/mass-assignment, auth).
|
||||
|
||||
```powershell
|
||||
# Если Ward установлен (pending — нет тегов-релизов, pin по commit SHA)
|
||||
.\bin\ward.exe scan --path app/
|
||||
```
|
||||
|
||||
Если Ward **не установлен** (pending `docs/security/ward-setup.md`) — отметить
|
||||
в отчёте как `PENDING` и продолжить. Ward — не блокер установки gate,
|
||||
но должен быть установлен до первого реального go-live.
|
||||
|
||||
Ожидаемо: **0 critical**. High — разобрать вручную. Ошибки конфигурации
|
||||
(APP_DEBUG=true, слабые ключи, открытые CORS) = NO-GO если critical.
|
||||
|
||||
### 1.4 Trail of Bits — глубокий on-demand аудит (#39)
|
||||
|
||||
Вызывается вручную перед первым публичным релизом или при значительных
|
||||
изменениях security-периметра. Не требуется при каждом хотфиксе.
|
||||
|
||||
```
|
||||
/differential-review:diff-review # если ревьюим конкретный diff
|
||||
/audit-context-building:audit-context # для supply-chain аудита
|
||||
```
|
||||
|
||||
Результаты фиксируются в `docs/security/trail-of-bits-YYYY-MM-DD.md`.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2 — ПДн / 152-ФЗ
|
||||
|
||||
Вызвать скил `pdn-152fz-audit` (#71).
|
||||
|
||||
```
|
||||
/pdn-152fz-audit
|
||||
```
|
||||
|
||||
Прогнать оба режима:
|
||||
|
||||
- **Режим 1 (технический):** RLS на таблицах ПДн, маскирование pg_anonymizer,
|
||||
отсутствие phone/email в логах, pg_anonymizer в дампах.
|
||||
- **Режим 2 (соответствие 152-ФЗ):** хранение в РФ, согласия, права субъекта
|
||||
(`pd_subject_requests`), журнал обработки (`pd_processing_log`), уведомление РКН.
|
||||
|
||||
Итог: список нарушений (если есть). Нарушения Режима 1 уровня critical (ПДн
|
||||
в открытых логах/Sentry) = NO-GO.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3 — Угрозы (threat model)
|
||||
|
||||
Вызвать скил `threat-model` (#72) или открыть последний файл
|
||||
`docs/security/threat-model-YYYY-MM-DD.md`.
|
||||
|
||||
Цель: убедиться, что **топ-приоритетные угрозы из STRIDE** закрыты контрмерами
|
||||
(rate-limit на login, HMAC на webhook, Sanctum token-auth, CSRF, RLS).
|
||||
|
||||
Если актуальная модель угроз отсутствует (нет файла за последние 30 дней) —
|
||||
запустить `threat-model` перед динамикой.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4 — Динамика (dynamic analysis, локальная цель)
|
||||
|
||||
> **IS8:** по умолчанию цель — локальная копия. Убедиться, что приложение
|
||||
> запущено: `php artisan serve` → `http://127.0.0.1:8000`.
|
||||
|
||||
### 4.1 Nuclei — широкое сканирование (#69)
|
||||
|
||||
Nuclei установлен как CLI-бинарь `bin/nuclei.exe` (MIT, projectdiscovery,
|
||||
v3.8.0). **Не MCP-сервер.**
|
||||
|
||||
**Квирки native-Windows (обязательно соблюдать):**
|
||||
|
||||
1. **Цель — `127.0.0.1`, НЕ `localhost`.** Резолвер Nuclei не разрешает
|
||||
`localhost` на этой машине — цель будет пропущена (квирк зафиксирован в
|
||||
`docs/security/nuclei-setup.md`).
|
||||
2. **Низкий rate-limit для dev-сервера.** `php artisan serve` однопоточный;
|
||||
без ограничений Nuclei перегружает его ложными connection-ошибками.
|
||||
Всегда использовать `-rate-limit 20 -c 5`.
|
||||
|
||||
```powershell
|
||||
# Стандартный прогон (medium+)
|
||||
bin\nuclei.exe -u "http://127.0.0.1:8000" `
|
||||
-rate-limit 20 -c 5 -timeout 5 -duc `
|
||||
-severity medium,high,critical
|
||||
|
||||
# Только технологический стек (быстрый smoke)
|
||||
bin\nuclei.exe -u "http://127.0.0.1:8000" -tags tech `
|
||||
-rate-limit 20 -c 5 -timeout 5 -duc
|
||||
```
|
||||
|
||||
Если `bin/nuclei.exe` отсутствует — отметить `PENDING` и продолжить.
|
||||
Детали установки: `docs/security/nuclei-setup.md`.
|
||||
|
||||
Ожидаемо: **0 critical/high**. Medium — разобрать вручную.
|
||||
|
||||
### 4.2 ZAP — глубокое DAST (#68)
|
||||
|
||||
ZAP (#68) — официальный MCP add-on (`zaproxy/zap-extensions`, Apache-2.0),
|
||||
alpha v0.1.0. Требует Java 17+ и запущенного ZAP-демона.
|
||||
|
||||
Если ZAP **не установлен** (pending Java) — отметить `PENDING` и продолжить.
|
||||
Детали: `docs/security/zap-setup.md` (когда будет создан).
|
||||
|
||||
```
|
||||
# Через ZAP MCP (когда ZAP установлен)
|
||||
# 1. Запустить ZAP-демон: zaproxy -daemon -port 8080 -config api.key=<key>
|
||||
# 2. Spider
|
||||
ZapStartSpiderTool(url="http://127.0.0.1:8000", contextId=...)
|
||||
# 3. Active scan
|
||||
ZapStartActiveScanTool(url="http://127.0.0.1:8000", contextId=...)
|
||||
# 4. Отчёт
|
||||
ZapGenerateReportTool(...)
|
||||
```
|
||||
|
||||
Ожидаемо: **0 critical/high**. Medium — разобрать вручную.
|
||||
Critical/high из ZAP active scan = NO-GO.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5 — Сбор находок и вердикт
|
||||
|
||||
### Severity → статус
|
||||
|
||||
| Severity | Источник | Статус gate |
|
||||
|---|---|---|
|
||||
| critical | любой инструмент | **NO-GO** (блокер) |
|
||||
| high | любой инструмент | **NO-GO** (блокер) |
|
||||
| medium | любой инструмент | Предупреждение (не блокирует go-live, фиксируется) |
|
||||
| low / info | любой инструмент | Информационно |
|
||||
| PENDING | ZAP / Ward / Nuclei не установлены | Условный GO — инструменты должны быть установлены до публичного деплоя |
|
||||
|
||||
### Формат отчёта
|
||||
|
||||
```
|
||||
=== SECURITY GO-LIVE REPORT ===
|
||||
Дата: YYYY-MM-DD
|
||||
Версия схемы: vX.XX
|
||||
Commit: <git rev-parse HEAD>
|
||||
Цель: http://127.0.0.1:<port> (локальная копия)
|
||||
|
||||
--- ШАГ 1: СТАТИКА ---
|
||||
gitleaks: OK (0 утечек) / FAIL (<N> утечек)
|
||||
Semgrep: OK (0 critical/high) / FAIL (<список>)
|
||||
Ward: OK / FAIL (<список>) / PENDING (не установлен)
|
||||
Trail of Bits: OK / SKIP (не применимо к этому прогону)
|
||||
|
||||
--- ШАГ 2: ПДн / 152-ФЗ ---
|
||||
pdn-152fz-audit Режим 1: OK / FAIL (<список>)
|
||||
pdn-152fz-audit Режим 2: OK / ПРЕДУПРЕЖДЕНИЯ (<список>)
|
||||
|
||||
--- ШАГ 3: УГРОЗЫ ---
|
||||
threat-model: ЗАКРЫТЫ (файл docs/security/threat-model-YYYY-MM-DD.md)
|
||||
Незакрытые топ-угрозы: <список или "нет">
|
||||
|
||||
--- ШАГ 4: ДИНАМИКА ---
|
||||
Nuclei: OK (0 critical/high) / FAIL (<список>) / PENDING (не установлен)
|
||||
ZAP: OK (0 critical/high) / FAIL (<список>) / PENDING (не установлен)
|
||||
|
||||
=== ВЕРДИКТ: GO ✅ / NO-GO ❌ ===
|
||||
Блокеры (critical/high):
|
||||
- <инструмент>: <описание> — <рекомендация>
|
||||
(или "Блокеров нет")
|
||||
|
||||
Предупреждения (medium):
|
||||
- <инструмент>: <описание>
|
||||
(или "Предупреждений нет")
|
||||
|
||||
PENDING-инструменты (должны быть закрыты до публичного деплоя):
|
||||
- Ward #70: установка — docs/security/ward-setup.md
|
||||
- ZAP #68: установка — docs/security/zap-setup.md (pending Java)
|
||||
(или "Все инструменты установлены")
|
||||
=== END ===
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Типичные блокеры и действия
|
||||
|
||||
| Находка | Источник | Действие |
|
||||
|---|---|---|
|
||||
| APP\_DEBUG=true | Ward / Semgrep | Исправить `.env` перед деплоем |
|
||||
| Секрет в git-истории | gitleaks | Rotate + `git filter-repo`; НЕ деплоить |
|
||||
| ПДн в логах Laravel | pdn-152fz-audit | Убрать из LogChannel + Sentry scrubbing |
|
||||
| CSRF отключён | Ward | Проверить `VerifyCsrfToken` middleware |
|
||||
| Слабый APP\_KEY | Ward | `php artisan key:generate` |
|
||||
| Критическая CVE в зависимости | Semgrep / Ward | `composer update` или `npm update` |
|
||||
| SQL injection / XSS | ZAP / Nuclei | Исправить код, перепрогнать |
|
||||
| Незакрытая STRIDE-угроза | threat-model | Реализовать контрмеру или принять риск с заказчиком |
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
name: threat-model
|
||||
description: Моделирование угроз портала Лидерра по STRIDE — карта точек входа, что меняется при выходе в интернет, приоритизация защиты. Используй при «смоделируй угрозы», «откуда могут атаковать», «что защищать в первую очередь перед публикацией», «карта точек входа», «threat model / STRIDE». НЕ для аудита ПДн/152-ФЗ (pdn-152fz-audit #71), статического security-аудита кода (D3/Semgrep/Trail of Bits), generic архитектурных паттернов (architecture-patterns), go-live прогона (security-go-live #73).
|
||||
---
|
||||
|
||||
# Threat Model — моделирование угроз портала Лидерра
|
||||
|
||||
Проектный скил раздела A8 карты «Информационная безопасность». Применяет методологию
|
||||
**STRIDE** к реальным точкам входа портала и отвечает на главный вопрос перед
|
||||
публикацией: **что именно меняется, когда в систему может зайти любой из интернета**.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- «Смоделируй угрозы» / «откуда могут атаковать» / «что защищать в первую очередь»
|
||||
- Подготовка к go-live — составление модели угроз как артефакта (отдельно от
|
||||
чек-листа запуска, который — в `security-go-live #73`)
|
||||
- Анализ конкретного эндпоинта: «насколько опасен открытый `/api/webhook/{token}`?»
|
||||
- Ответ на вопрос заказчика / регулятора «покажи модель угроз»
|
||||
|
||||
## Процедура STRIDE для Лидерры
|
||||
|
||||
Полный разбор точек входа и таблица угроз — `references/stride-portal.md`.
|
||||
|
||||
### Шаги
|
||||
|
||||
1. **Определить периметр** — что сейчас открыто наружу vs что будет открыто после
|
||||
публикации. Основа: список точек входа в `references/stride-portal.md`.
|
||||
2. **Пройти по STRIDE для каждой точки** — заполнить 6 строк (S/T/R/I/D/E).
|
||||
Опираться на таблицу в `references/stride-portal.md`; при новых эндпоинтах
|
||||
добавлять строки по тому же шаблону.
|
||||
3. **Оценить вероятность × ущерб** — приоритизировать по матрице из `references/stride-portal.md`.
|
||||
4. **Сформировать список контрмер** — что уже есть (RLS, HMAC, Sanctum, rate-limit),
|
||||
чего не хватает (rate-limit на login, WAF, 2FA enforcement, и т.д.).
|
||||
5. **Сохранить результат** в `docs/security/threat-model-YYYY-MM-DD.md`.
|
||||
|
||||
## Выход
|
||||
|
||||
Файл `docs/security/threat-model-<дата>.md` со структурой:
|
||||
|
||||
- Область действия (дата, версия схемы, commit)
|
||||
- Карта точек входа (таблица)
|
||||
- STRIDE по каждой точке
|
||||
- Дельта «был закрытый круг → стал интернет»
|
||||
- Приоритизированный список рисков с контрмерами
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `pdn-152fz-audit` #71 — тот про *персональные данные и 152-ФЗ* (конкретные
|
||||
таблицы, согласия, права субъекта); threat-model про *вектора атак и защиту
|
||||
эндпоинтов*.
|
||||
- ≠ D3 audit-security (#39/#40 Trail of Bits / Semgrep) — те про *статический
|
||||
анализ кода на уязвимости*; threat-model про *архитектурную карту угроз*.
|
||||
- ≠ `architecture-patterns` #38 — тот generic-паттерны; threat-model — конкретный
|
||||
портал, конкретные маршруты.
|
||||
- ≠ `security-go-live` #73 — тот *прогоняет конкретный чек-лист* перед релизом
|
||||
(Nmap, заголовки, CVE, gitleaks, DAST); threat-model *строит модель угроз как
|
||||
документ* (вход для чек-листа и приоритизации работ).
|
||||
|
||||
## Связано
|
||||
|
||||
- `references/stride-portal.md` — детальная карта точек входа и STRIDE-таблица.
|
||||
- `pdn-152fz-audit` #71 — смежный аудит ПДн; часто запускается вместе с threat-model.
|
||||
- `security-go-live` #73 — операционный прогон после threat-model завершён.
|
||||
- D3 / Semgrep #25 / Trail of Bits #39 — статический анализ; дополняет threat-model
|
||||
на уровне кода.
|
||||
- ADR-013 (infosec-tooling A8).
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"skill": "threat-model",
|
||||
"cases": [
|
||||
{"prompt": "смоделируй угрозы при выходе портала в интернет", "should_trigger": true},
|
||||
{"prompt": "что защищать в первую очередь перед публикацией", "should_trigger": true},
|
||||
{"prompt": "откуда могут атаковать портал", "should_trigger": true},
|
||||
{"prompt": "составь карту точек входа", "should_trigger": true},
|
||||
{"prompt": "сделай threat model по STRIDE", "should_trigger": true},
|
||||
{"prompt": "проверь соответствие 152-ФЗ", "should_trigger": false, "expected": "pdn-152fz-audit"},
|
||||
{"prompt": "прогони все проверки безопасности перед релизом", "should_trigger": false, "expected": "security-go-live"},
|
||||
{"prompt": "просканируй код на уязвимости семгрепом", "should_trigger": false, "expected": "D3/Semgrep"}
|
||||
]
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
# STRIDE — карта угроз портала Лидерра
|
||||
|
||||
Основан на реальных маршрутах `app/routes/web.php` (v8.26, 21.05.2026).
|
||||
Стек: Laravel 13 + Vue 3 + PostgreSQL 16 RLS + Redis, Yandex Cloud `ru-central1`.
|
||||
|
||||
---
|
||||
|
||||
## Карта точек входа
|
||||
|
||||
| # | Точка входа | Маршрут(ы) | Аутентификация |
|
||||
|---|---|---|---|
|
||||
| E1 | Вход / регистрация | `POST /api/auth/login`, `POST /api/auth/register` | Публичный |
|
||||
| E2 | 2FA и коды восстановления | `POST /api/auth/2fa/verify`, `POST /api/auth/2fa/recovery-use` | Публичный (pending-session) |
|
||||
| E3 | Сброс пароля | `POST /api/auth/forgot`, `POST /api/auth/reset-password` | Публичный |
|
||||
| E4 | Входящий webhook поставщика | `POST /api/webhook/supplier/{secret}` | URL-secret + IP-allowlist |
|
||||
| E5 | Входящий webhook тенанта | `POST /api/webhook/{token}` | URL-token + (prod: HMAC X-Webhook-Signature + rate-limit) |
|
||||
| E6 | API сделок | `GET/POST/PATCH/DELETE /api/deals`, `/api/deals/export`, `/api/deals/transition`, `/api/deals/restore` | Sanctum SPA + tenant |
|
||||
| E7 | API проектов | `GET/POST/PATCH/DELETE /api/projects/{id}`, `/api/projects/bulk`, `/api/projects/{id}/sync` | Sanctum SPA + tenant |
|
||||
| E8 | API импорта CSV | `POST /api/imports`, `GET /api/imports/{importLog}`, `/api/imports/unknown-statuses` | Sanctum SPA + tenant |
|
||||
| E9 | Lookup-эндпоинты | `GET /api/managers`, `GET /api/lead-statuses` | **Без auth** (открытые) |
|
||||
| E10 | Биллинг тенанта | `POST /api/billing/topup`, `GET /api/billing/wallet`, `/transactions`, `/invoices` | Sanctum SPA + tenant |
|
||||
| E11 | Charges ledger | `GET /api/billing/charges`, `POST /api/billing/charges/export` | Sanctum SPA + tenant |
|
||||
| E12 | API-ключи тенанта | `GET /api/api-keys`, `POST /api/api-keys/regenerate` | Sanctum SPA + tenant |
|
||||
| E13 | Webhook-настройки тенанта | `GET/PUT /api/tenants/me/webhook-settings`, `POST /api/webhooks/test` | Sanctum SPA + tenant |
|
||||
| E14 | Напоминания | `GET/POST/PATCH/DELETE /api/reminders/{id}` | Sanctum SPA + tenant |
|
||||
| E15 | Уведомления | `GET/PATCH/POST/DELETE /api/notifications/{id}` | Sanctum SPA + tenant |
|
||||
| E16 | Отчёты | `GET/POST/DELETE /api/reports/jobs/{id}`, `POST /{id}/retry`, `POST /{id}/cancel` | Sanctum SPA + tenant |
|
||||
| E17 | Скачивание отчёта | `GET /api/reports/jobs/{id}/file` | Signed URL (без Sanctum) |
|
||||
| E18 | Дашборд | `GET /api/dashboard/summary` | **Без auth** (MVP-заглушка) |
|
||||
| E19 | Профиль / уведомления-настройки | `GET/PATCH /api/auth/me`, `PATCH /api/auth/me/notification-preferences` | Sanctum SPA |
|
||||
| E20 | SaaS-admin: тенанты, биллинг, инциденты, система | `GET/PATCH /api/admin/**` | `saas-admin` middleware |
|
||||
| E21 | SaaS-admin: импersonation | `POST /api/admin/impersonation/init`, `/verify`, `/end` | `saas-admin` middleware |
|
||||
| E22 | SaaS-admin: supplier-integration | `GET/POST /api/admin/supplier-integration/**` | `saas-admin` middleware |
|
||||
| E23 | 2FA setup (авторизованный) | `POST /api/2fa/init`, `/confirm`, `/disable`, `/regenerate-recovery-codes` | Sanctum SPA |
|
||||
| E24 | SPA-оболочка | `GET /`, `/login`, `/register`, `/deals`, … (20+ маршрутов) | Без auth (Vue shell) |
|
||||
|
||||
---
|
||||
|
||||
## Дельта «закрытый круг → интернет»
|
||||
|
||||
До публикации портал доступен только команде (VPN или фиксированные IP).
|
||||
После публикации **любой актор из интернета** может обратиться к каждому публичному
|
||||
эндпоинту. Критические изменения:
|
||||
|
||||
| Изменение | Затронутые точки | Почему важно |
|
||||
|---|---|---|
|
||||
| Брутфорс и credential stuffing | E1 (login) | Нет rate-limit на `/api/auth/login` (на момент анализа) |
|
||||
| Энумерация пользователей | E1, E3 | Разные ответы на «существующий / несуществующий email» создают oracle |
|
||||
| Replay и forgery webhook | E4, E5 | Secret в URL виден в логах прокси/nginx; HMAC на E5 — «prod» (не в dev) |
|
||||
| Открытые lookup-эндпоинты | E9 | `GET /api/managers`, `GET /api/lead-statuses` без auth — раскрывают ФИО менеджеров |
|
||||
| Открытый дашборд | E18 | `GET /api/dashboard/summary` без auth — раскрывает KPI текущего тенанта |
|
||||
| DoS на artisan-сервере | Все | `php artisan serve` не держит нагрузку; нужен nginx/Octane |
|
||||
| SSRF через webhook-test | E13 | `POST /api/webhooks/test` отправляет запрос на URL из тела — риск SSRF во внутреннюю сеть YC |
|
||||
| Impersonation без prod-auth | E21 | `saas-admin` middleware в dev-режиме пропускает без проверки (`SAAS_ADMIN_TEST_BYPASS`) |
|
||||
| Signed URL без срока инвалидации | E17 | Отчёт с ПДн доступен 24 ч по ссылке без повторной аутентификации |
|
||||
|
||||
---
|
||||
|
||||
## STRIDE по точкам входа
|
||||
|
||||
### E1 — Вход / Регистрация (`POST /api/auth/login`, `POST /api/auth/register`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Брутфорс пароля, credential stuffing | Bcrypt-хеш пароля | Нет rate-limit на login |
|
||||
| **T** Tampering | Подмена `tenant_id` в теле запроса | `tenant_id` берётся из `auth()->user()`, не из тела | — |
|
||||
| **R** Repudiation | Отрицание входа | `auth_log` пишет login/logout | Нет IP + User-Agent в каждой записи |
|
||||
| **I** Info disclosure | Энумерация email через разные ответы | Unified-ответ на forgot (E3) | Login может раскрывать «нет такого пользователя» |
|
||||
| **D** DoS | Флуд регистраций, засорение БД | — | Нет captcha / email-верификации на register |
|
||||
| **E** Elevation | Регистрация с `is_admin=true` в теле | Mass-assignment guard (fillable) | Проверить `$fillable` в `User` — нет ли `role` |
|
||||
|
||||
### E2 — 2FA (`POST /api/auth/2fa/verify`, `POST /api/auth/2fa/recovery-use`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Брутфорс 6-значного TOTP | TOTP 30-сек окно | Нет rate-limit на `/2fa/verify` |
|
||||
| **T** Tampering | Подмена `pending_user_id` в session | Серверная session | Проверить изоляцию session между тенантами |
|
||||
| **R** Repudiation | Использование кода восстановления | `auth_log` | Фиксируется ли `recovery_used` событие? |
|
||||
| **I** Info disclosure | Тайминг-атака на сравнение TOTP | TOTP библиотека (constant-time?) | Проверить реализацию `verifyTwoFactor` |
|
||||
| **D** DoS | Флуд на `/2fa/verify` истощает session-store | — | Нет rate-limit |
|
||||
| **E** Elevation | Обход 2FA через `recovery-use` | Коды — одноразовые, хранятся hashed | Если коды в открытом виде — критично |
|
||||
|
||||
### E3 — Сброс пароля (`POST /api/auth/forgot`, `POST /api/auth/reset-password`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Захват аккаунта через сброс пароля чужого email | Токен по email | Нет rate-limit на `/forgot` |
|
||||
| **T** Tampering | Подмена токена сброса | Cryptographic token (Laravel default) | Проверить срок жизни токена (1 ч?) |
|
||||
| **R** Repudiation | — | — | — |
|
||||
| **I** Info disclosure | Энумерация email через тайминг ответа | Unified-ответ задокументирован в роутах | Проверить фактическую реализацию ответа |
|
||||
| **D** DoS | Флуд `/forgot` → очередь email | — | Нет rate-limit → перегрузка Unisender Go |
|
||||
| **E** Elevation | — | — | — |
|
||||
|
||||
### E4 — Webhook поставщика (`POST /api/webhook/supplier/{secret}`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Подделка запроса от crm.bp-gr.ru | URL-secret + IP allowlist (`system_settings.supplier_ip_allowlist`) | Secret виден в логах nginx/прокси |
|
||||
| **T** Tampering | Подмена payload (телефон, стоимость лида) | — | Нет HMAC на тело; только secret в URL |
|
||||
| **R** Repudiation | Отрицание доставки лида | `supplier_leads.raw_payload` | Нет timestamp-подписи для доказательства |
|
||||
| **I** Info disclosure | Secret в URL → в access-логах сервера | IP allowlist сужает круг | Ротация secret при компрометации? |
|
||||
| **D** DoS | Флуд поддельных лидов → списание баланса | IP allowlist | Если allowlist обходится (SSRF) |
|
||||
| **E** Elevation | Подмена `tenant_id` в payload | Берётся из `system_settings` глобально | Архитектурно корректно; проверить lookup |
|
||||
|
||||
### E5 — Webhook тенанта (`POST /api/webhook/{token}`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Запрос от неавторизованного источника | URL-token из `tenants.webhook_token`; HMAC X-Webhook-Signature (prod) | HMAC только в prod; dev уязвим |
|
||||
| **T** Tampering | Изменение payload в transit | HMAC-валидация (prod) | В dev отключена — нельзя тестировать на prod-данных |
|
||||
| **R** Repudiation | — | `supplier_leads.raw_payload` | — |
|
||||
| **I** Info disclosure | Token в URL виден в логах | Per-token rate-limit | Нет ротации token при смене API-ключа |
|
||||
| **D** DoS | Replay flood | Per-token rate-limit (prod) | Нет в dev |
|
||||
| **E** Elevation | Лид с завышенной ценой | Стоимость берётся из `PricingTierResolver`, не из payload | Архитектурно защищено |
|
||||
|
||||
### E9 — Открытые lookup-эндпоинты (`GET /api/managers`, `GET /api/lead-statuses`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | — | — | — |
|
||||
| **T** Tampering | — | — | — |
|
||||
| **R** Repudiation | — | — | — |
|
||||
| **I** Info disclosure | ФИО менеджеров без аутентификации | — | **Нет auth** — любой из интернета получает список менеджеров |
|
||||
| **D** DoS | Флуд запросами | — | Нет rate-limit |
|
||||
| **E** Elevation | — | — | — |
|
||||
|
||||
### E18 — Дашборд без auth (`GET /api/dashboard/summary`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **I** Info disclosure | KPI, баланс, активность тенанта без аутентификации | — | **MVP-заглушка**: auth не включён; в prod обязателен |
|
||||
| **D** DoS | Тяжёлый агрегационный запрос без auth | — | Доступен без токена |
|
||||
|
||||
### E20 — SaaS-admin (`GET/PATCH /api/admin/**`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Доступ к admin-панели без Yandex 360 SSO | `saas-admin` middleware (fail-closed 503 в prod) | SSO не реализован до Б-1; `SAAS_ADMIN_TEST_BYPASS` в prod = полный доступ |
|
||||
| **T** Tampering | Изменение тарифа, статуса тенанта без аудита | `saas_admin_audit_log` | — |
|
||||
| **R** Repudiation | Отрицание действий admin | `saas_admin_audit_log` | Нет подписи/2FA для деструктивных операций |
|
||||
| **I** Info disclosure | Данные всех тенантов | `saas-admin` middleware | SAAS_ADMIN_TEST_BYPASS=true в production = полный дамп |
|
||||
| **D** DoS | Bulk-delete тенантов | — | Нет подтверждения для деструктивных bulk-операций |
|
||||
| **E** Elevation | Impersonation любого тенанта | `saas-admin` middleware | Та же уязвимость через bypass |
|
||||
|
||||
### E21 — Impersonation (`POST /api/admin/impersonation/init`, `/verify`, `/end`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **S** Spoofing | Имперсонация без реального admin-права | `saas-admin` middleware | Bypass в dev/test режиме |
|
||||
| **T** Tampering | Изменение `admin_user_id` в токене | Token-based flow | Проверить, что token не forgeble |
|
||||
| **R** Repudiation | Отрицание сессии имперсонации | `impersonation_tokens` логирует | Нет нотификации целевому тенанту |
|
||||
| **E** Elevation | Получение прав тенанта через impersonation | Scope ограничен tenant-контекстом | Если RLS bypass во время импersонации |
|
||||
|
||||
### E13 — SSRF через webhook-test (`POST /api/webhooks/test`)
|
||||
|
||||
| Угроза | Описание | Текущий контроль | Пробел |
|
||||
|---|---|---|---|
|
||||
| **T** Tampering | Отправка запроса на внутренний адрес YC | — | **Нет фильтрации URL** — SSRF во внутреннюю сеть Yandex Cloud (metadata service 169.254.169.254) |
|
||||
| **I** Info disclosure | YC instance metadata (IAM-токен, настройки сети) | — | Критично: SSRF → metadata API → IAM credentials |
|
||||
|
||||
---
|
||||
|
||||
## Приоритизация рисков
|
||||
|
||||
Матрица: **Вероятность** (В — высокая / С — средняя / Н — низкая) ×
|
||||
**Ущерб** (К — критический / В — высокий / С — средний / Н — низкий).
|
||||
|
||||
| Приоритет | Риск | Точка | Вероятность | Ущерб | Контрмера |
|
||||
|---|---|---|---|---|---|
|
||||
| 🔴 P0 | SAAS_ADMIN_TEST_BYPASS=true в prod | E20, E21 | В | К | Убедиться, что флаг false в `.env.production`; fail-closed middleware |
|
||||
| 🔴 P0 | SSRF через `/api/webhooks/test` | E13 | С | К | Валидировать URL: запрещать RFC1918 + link-local + metadata-IP; использовать DNS-rebind защиту |
|
||||
| 🔴 P0 | `GET /api/dashboard/summary` без auth | E18 | В | В | Добавить `auth:sanctum + tenant` middleware до prod |
|
||||
| 🔴 P0 | `GET /api/managers`, `GET /api/lead-statuses` без auth | E9 | В | С | Добавить `auth:sanctum + tenant` |
|
||||
| 🟠 P1 | Нет rate-limit на login / forgot / 2fa/verify | E1, E2, E3 | В | В | Laravel Throttle middleware (e.g. `throttle:5,1`) |
|
||||
| 🟠 P1 | URL-secret поставщика виден в access-логах | E4 | С | В | Перевести на HMAC-заголовок; ротировать secret; закрыть логи |
|
||||
| 🟠 P1 | Флуд поддельных лидов → списание баланса | E4, E5 | С | В | IP allowlist жёсткий; HMAC на тело (E4); idempotency-key |
|
||||
| 🟡 P2 | Энумерация email на login (не только forgot) | E1 | В | С | Unified-ответ на login тоже |
|
||||
| 🟡 P2 | Флуд регистраций без email-верификации | E1 | С | С | Email verification или captcha |
|
||||
| 🟡 P2 | Signed URL отчёта 24 ч без аутентификации | E17 | Н | С | Сократить TTL; добавить revocation при logout |
|
||||
| 🟡 P2 | Нет нотификации тенанту при impersonation | E21 | Н | С | Email/in-app уведомление при входе admin |
|
||||
| 🟢 P3 | Тайминг-атака на TOTP | E2 | Н | С | Проверить constant-time compare в TwoFactorController |
|
||||
| 🟢 P3 | Тайминг-атака на email в forgot | E3 | Н | Н | Unified-ответ + jitter sleep |
|
||||
|
||||
---
|
||||
|
||||
## Что уже защищает портал (baseline)
|
||||
|
||||
- **RLS PostgreSQL** — 39 политик; кросс-tenant утечка через SQL закрыта.
|
||||
- **Sanctum SPA auth** — все бизнес-эндпоинты под `auth:sanctum + tenant`.
|
||||
- **Per-token rate-limit** — на входящих webhook'ах тенанта (E5).
|
||||
- **IP allowlist** — на webhook поставщика (E4).
|
||||
- **HMAC X-Webhook-Signature** — на E5 в prod (не в dev).
|
||||
- **`auth_log`** — фиксирует login/logout события.
|
||||
- **`saas_admin_audit_log`** — фиксирует admin-действия.
|
||||
- **Bcrypt** — хеш пароля; коды восстановления 2FA — hashed.
|
||||
- **`saas-admin` middleware** — fail-closed 503 в prod (если `SAAS_ADMIN_TEST_BYPASS=false`).
|
||||
- **Signed URL** — для скачивания отчётов (E17).
|
||||
- **gitleaks** — pre-commit/pre-push; секреты не должны попасть в репозиторий.
|
||||
@@ -4,23 +4,4 @@
|
||||
# Nuclei docs `-u http://...` — nuclei's -u flag is "target URL", not curl basic-auth.
|
||||
# Rule `curl-auth-user` matches the pattern but it's not authentication.
|
||||
f696ca50266eb1c2974b5fc89f6fa585edaf4b6b:docs/security/nuclei-setup.md:curl-auth-user:27
|
||||
|
||||
# 2026-05-22 evening — rt-add-project-form.yml в stash (untracked файл captured при stash push -u
|
||||
# до checkout main). Стэш не пушится, но gitleaks-full-history сканит refs/stash. Эти телефоны —
|
||||
# реальные данные supplier-формы, не наша утечка; rt-add-project-form.yml в .gitignore.
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:912
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:921
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:941
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:950
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:970
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:979
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3811
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3820
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3840
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3849
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3869
|
||||
229beb2d4ef190f6e29dd9ad31a642535601154f:rt-add-project-form.yml:ru-phone-unmasked:3878
|
||||
|
||||
# 2026-05-22 — nuclei-setup.md curl-auth-user тот же FP что и раньше (f696ca5),
|
||||
# но коммит другой (05437ba) — параллельная сессия пере-коммитила тот же файл.
|
||||
05437ba79a26a7a7bbbe0ffb2f2573c432a9a4d1:docs/security/nuclei-setup.md:curl-auth-user:27
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Однократный back-fill: для каждого site-проекта с identifier-субдоменом —
|
||||
* добавить линки к supplier_projects на корневом домене (если те уже существуют).
|
||||
*
|
||||
* Идемпотентна (insertOrIgnore эквивалент: явная проверка наличия + DEFAULT NOW
|
||||
* на created_at в схеме).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.3
|
||||
*/
|
||||
class BackfillRootSupplierLinksCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:backfill-root-links {--dry-run : показать что бы добавилось, не писать в БД}';
|
||||
|
||||
protected $description = 'Back-fill линков project_supplier_links к корневому домену для проектов-субдоменов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$scanned = 0;
|
||||
$added = 0;
|
||||
$skippedAlreadyRoot = 0;
|
||||
$skippedNoRootSp = 0;
|
||||
|
||||
$projects = Project::on('pgsql_supplier')
|
||||
->where('signal_type', 'site')
|
||||
->whereExists(function ($q): void {
|
||||
$q->select(DB::raw(1))
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id');
|
||||
})
|
||||
->get(['id', 'signal_identifier']);
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$scanned++;
|
||||
$root = SupplierIdentifier::extractRootDomain((string) $project->signal_identifier);
|
||||
if ($root === null) {
|
||||
$skippedAlreadyRoot++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rootSps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', $root)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
|
||||
if ($rootSps->isEmpty()) {
|
||||
$skippedNoRootSp++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rootSps as $rootSp) {
|
||||
$alreadyExists = DB::connection('pgsql_supplier')
|
||||
->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists();
|
||||
|
||||
if ($alreadyExists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%sScanned: %d / Added: %d / Skipped (already root): %d / Skipped (no root sp): %d',
|
||||
$dryRun ? '[DRY-RUN] ' : '',
|
||||
$scanned,
|
||||
$added,
|
||||
$skippedAlreadyRoot,
|
||||
$skippedNoRootSp,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Supplier\Import\SupplierProjectImporter;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Разовый импорт активных проектов поставщика (аккаунт lkomega) как проектов
|
||||
* Лидерры под тенантом владельца. По умолчанию dry-run (печатает план, ничего
|
||||
* не пишет). С --commit пишет в БД через pgsql_supplier (BYPASSRLS), портал НЕ
|
||||
* трогает. Идемпотентна.
|
||||
*
|
||||
* Plan: docs/superpowers/plans/2026-05-22-supplier-projects-import-lkomega.md
|
||||
*/
|
||||
class ImportSupplierProjectsCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:import-projects
|
||||
{--tenant= : email пользователя тенанта (напр. info@lkomega.ru)}
|
||||
{--commit : выполнить запись (без флага — только dry-run)}';
|
||||
|
||||
protected $description = 'Усыновить активные проекты поставщика как проекты Лидерры под тенантом (dry-run по умолчанию)';
|
||||
|
||||
public function handle(SupplierProjectImporter $importer): int
|
||||
{
|
||||
$email = (string) $this->option('tenant');
|
||||
if ($email === '') {
|
||||
$this->error('Укажите --tenant=<email>');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tenantId = User::on('pgsql_supplier')->where('email', $email)->value('tenant_id');
|
||||
if ($tenantId === null) {
|
||||
$this->error("Тенант для email '{$email}' не найден.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$plan = $importer->buildPlan((int) $tenantId);
|
||||
|
||||
$this->info(sprintf('Тенант %s (id=%d). К созданию: %d проектов. Пропущено строк/групп: %d.',
|
||||
$email, $tenantId, count($plan['planned']), count($plan['skipped'])));
|
||||
|
||||
$this->table(
|
||||
['Тип', 'Идентификатор', 'Тег', 'Регионы', 'Лимит', 'Площадки (external_id)'],
|
||||
array_map(fn (array $p): array => [
|
||||
$p['signal_type'],
|
||||
$this->mask($p['signal_identifier'] ?? ($p['sms_senders'][0] ?? '')),
|
||||
mb_substr((string) $p['tag'], 0, 30),
|
||||
$p['regions'] === [] ? 'вся РФ' : implode(',', $p['regions']),
|
||||
(string) $p['daily_limit_target'],
|
||||
collect($p['platforms'])->map(fn (array $pl): string => $pl['platform'].':'.$pl['external_id'])->implode(' '),
|
||||
], $plan['planned']),
|
||||
);
|
||||
|
||||
if ($plan['skipped'] !== []) {
|
||||
$this->warn('Пропуски:');
|
||||
foreach ($plan['skipped'] as $s) {
|
||||
$this->line(sprintf(' - [%s] %s', $s['reason'], $this->mask($s['label'])));
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->option('commit')) {
|
||||
$this->comment('DRY-RUN: ничего не записано. Повторите с --commit для реальной записи.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$result = $importer->commit($plan, (int) $tenantId);
|
||||
$this->info(sprintf('Создано: проектов=%d, supplier_projects=%d, связок=%d.',
|
||||
$result['created_projects'], $result['created_supplier_projects'], $result['created_links']));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** Маскирует цифровые хвосты (телефоны) для вывода (152-ФЗ). */
|
||||
private function mask(string $value): string
|
||||
{
|
||||
return (string) preg_replace_callback('/\d{4,}/', static fn (array $m): string => substr($m[0], 0, 2).str_repeat('*', max(0, strlen($m[0]) - 4)).substr($m[0], -2), $value);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -70,16 +69,6 @@ class ReportsCleanupExpired extends Command
|
||||
|
||||
if (! $dryRun) {
|
||||
Storage::disk('local')->delete($job->file_path);
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'deleted',
|
||||
subjectType: 'lead',
|
||||
subjectId: null,
|
||||
purpose: 'report_cleanup_expired_'.$job->id,
|
||||
tenantId: $job->tenant_id,
|
||||
actorTenantUserId: null,
|
||||
actorAdminUserId: null,
|
||||
ip: null,
|
||||
);
|
||||
$job->update(['file_path' => null]);
|
||||
}
|
||||
$count++;
|
||||
|
||||
@@ -6,13 +6,16 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Requests\Auth\RegisterStartRequest;
|
||||
use App\Http\Requests\Auth\RegisterVerifyRequest;
|
||||
use App\Mail\RegisterEmailVerificationCode;
|
||||
use App\Mail\SuspiciousLoginNotification;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -56,6 +59,21 @@ class AuthController extends Controller
|
||||
/** Лимит неудач входа с одного IP за час (ТЗ §22.4.4 п.2). */
|
||||
private const IP_LOCKOUT_THRESHOLD = 10;
|
||||
|
||||
/** Лимит отправок кода регистрации на email|ip за час. */
|
||||
private const REGISTER_MAX_SENDS = 5;
|
||||
|
||||
/** Окно лимита отправок (сек). */
|
||||
private const REGISTER_SEND_DECAY = 3600;
|
||||
|
||||
/** Срок жизни кода подтверждения (мин). */
|
||||
private const CODE_TTL_MINUTES = 15;
|
||||
|
||||
/** Лимит неверных вводов кода до сброса pending. */
|
||||
private const CODE_MAX_ATTEMPTS = 5;
|
||||
|
||||
/** Cooldown между повторными отправками кода (сек). */
|
||||
private const RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->only(['email', 'password']);
|
||||
@@ -128,10 +146,84 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
/**
|
||||
* Шаг 1 регистрации: валидирует форму, генерирует 6-значный код,
|
||||
* кладёт pending-данные в session, шлёт код письмом. Аккаунт НЕ создаётся.
|
||||
*/
|
||||
public function registerStart(RegisterStartRequest $request): JsonResponse
|
||||
{
|
||||
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
|
||||
// Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
|
||||
$email = mb_strtolower($request->string('email')->toString());
|
||||
$key = 'auth:register:'.$email.'|'.($request->ip() ?? 'unknown');
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::REGISTER_MAX_SENDS)) {
|
||||
return $this->lockoutResponse($key);
|
||||
}
|
||||
RateLimiter::hit($key, self::REGISTER_SEND_DECAY);
|
||||
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
$request->session()->put('registration.pending', [
|
||||
'email' => $email,
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
'phone' => $request->string('phone')->toString(),
|
||||
'code_hash' => hash('sha256', $code),
|
||||
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES)->toIso8601String(),
|
||||
'attempts' => 0,
|
||||
'last_sent_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
Mail::to($email)->send(new RegisterEmailVerificationCode($code));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Код подтверждения отправлен на указанный email.',
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаг 2 регистрации: проверяет код из session-pending; при успехе создаёт
|
||||
* аккаунт с email_verified_at=now() и логинит пользователя.
|
||||
*
|
||||
* Все ошибки кода отдаём под ключом 'code' (422) — единая точка показа на фронте.
|
||||
*/
|
||||
public function registerVerify(RegisterVerifyRequest $request): JsonResponse
|
||||
{
|
||||
$pending = $request->session()->get('registration.pending');
|
||||
|
||||
if (! is_array($pending)) {
|
||||
return $this->codeError('Регистрация не начата или истекла. Начните заново.');
|
||||
}
|
||||
|
||||
if (now()->greaterThan(Carbon::parse($pending['expires_at']))) {
|
||||
$request->session()->forget('registration.pending');
|
||||
|
||||
return $this->codeError('Срок действия кода истёк. Запросите новый код.');
|
||||
}
|
||||
|
||||
if ($pending['attempts'] >= self::CODE_MAX_ATTEMPTS) {
|
||||
$request->session()->forget('registration.pending');
|
||||
|
||||
return $this->codeError('Слишком много попыток. Начните регистрацию заново.');
|
||||
}
|
||||
|
||||
$input = $request->string('code')->toString();
|
||||
if (! hash_equals($pending['code_hash'], hash('sha256', $input))) {
|
||||
$pending['attempts']++;
|
||||
$request->session()->put('registration.pending', $pending);
|
||||
|
||||
return $this->codeError('Неверный код.');
|
||||
}
|
||||
|
||||
// Код верен. Перепроверяем уникальность email (гонка между start и verify).
|
||||
if (User::where('email', $pending['email'])->exists()) {
|
||||
$request->session()->forget('registration.pending');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Аккаунт с таким email уже существует.',
|
||||
'errors' => ['email' => ['Аккаунт с таким email уже существует.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::first();
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
@@ -141,14 +233,17 @@ class AuthController extends Controller
|
||||
|
||||
$user = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $request->string('email')->toString(),
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
'email' => $pending['email'],
|
||||
'password_hash' => $pending['password_hash'],
|
||||
'phone' => $pending['phone'],
|
||||
'first_name' => 'Новый',
|
||||
'last_name' => 'Пользователь',
|
||||
'is_active' => true,
|
||||
'totp_enabled' => false,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$request->session()->forget('registration.pending');
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
@@ -339,6 +434,57 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторная отправка кода: перегенерирует код, обновляет срок, шлёт письмо.
|
||||
* Cooldown RESEND_COOLDOWN_SECONDS между отправками (429 при нарушении).
|
||||
*/
|
||||
public function registerResend(Request $request): JsonResponse
|
||||
{
|
||||
$pending = $request->session()->get('registration.pending');
|
||||
|
||||
if (! is_array($pending)) {
|
||||
return $this->codeError('Регистрация не начата или истекла. Начните заново.');
|
||||
}
|
||||
|
||||
$elapsed = now()->getTimestamp() - Carbon::parse($pending['last_sent_at'])->getTimestamp();
|
||||
if ($elapsed < self::RESEND_COOLDOWN_SECONDS) {
|
||||
$retry = self::RESEND_COOLDOWN_SECONDS - $elapsed;
|
||||
|
||||
return response()->json([
|
||||
'message' => "Повторная отправка возможна через {$retry} сек.",
|
||||
'retry_after' => $retry,
|
||||
], 429)->header('Retry-After', (string) $retry);
|
||||
}
|
||||
|
||||
// Общий часовой лимит отправок — как у registerStart (spec §7.6):
|
||||
// cooldown не даёт спамить чаще 1/мин, лимит не даёт превысить 5/час.
|
||||
$key = 'auth:register:'.$pending['email'].'|'.($request->ip() ?? 'unknown');
|
||||
if (RateLimiter::tooManyAttempts($key, self::REGISTER_MAX_SENDS)) {
|
||||
return $this->lockoutResponse($key);
|
||||
}
|
||||
RateLimiter::hit($key, self::REGISTER_SEND_DECAY);
|
||||
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$pending['code_hash'] = hash('sha256', $code);
|
||||
$pending['expires_at'] = now()->addMinutes(self::CODE_TTL_MINUTES)->toIso8601String();
|
||||
$pending['last_sent_at'] = now()->toIso8601String();
|
||||
$pending['attempts'] = 0;
|
||||
$request->session()->put('registration.pending', $pending);
|
||||
|
||||
Mail::to($pending['email'])->send(new RegisterEmailVerificationCode($code));
|
||||
|
||||
return response()->json(['message' => 'Новый код отправлен на ваш email.']);
|
||||
}
|
||||
|
||||
/** 422 с ошибкой под ключом 'code'. */
|
||||
private function codeError(string $message): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
'errors' => ['code' => [$message]],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */
|
||||
private function lockoutResponse(string $throttleKey): JsonResponse
|
||||
{
|
||||
|
||||
@@ -28,9 +28,10 @@ class DashboardController extends Controller
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
// Go-live (audit J3): tenant_id из authed-user (auth:sanctum + tenant
|
||||
// middleware), НЕ из параметра запроса — закрывает кросс-tenant утечку KPI.
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -242,7 +241,7 @@ class DealController extends Controller
|
||||
* RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не
|
||||
* принадлежит tenant'у (или не существует) — 404.
|
||||
*/
|
||||
public function show(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
@@ -275,17 +274,6 @@ class DealController extends Controller
|
||||
return response()->json(['message' => 'Сделка не найдена.'], 404);
|
||||
}
|
||||
|
||||
$pdLog->record(
|
||||
action: 'viewed',
|
||||
subjectType: 'lead',
|
||||
subjectId: $deal->id,
|
||||
purpose: 'lead_card_view',
|
||||
tenantId: (int) $request->user()->tenant_id,
|
||||
actorTenantUserId: (int) $request->user()->id,
|
||||
actorAdminUserId: null,
|
||||
ip: $request->ip(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'deal' => [
|
||||
'id' => $deal->id,
|
||||
@@ -460,7 +448,7 @@ class DealController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/deals — manual create */
|
||||
public function store(Request $request, PdAuditLogger $pdLog): JsonResponse
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_name' => 'required|string|max:255',
|
||||
@@ -543,13 +531,6 @@ class DealController extends Controller
|
||||
return $deal;
|
||||
});
|
||||
|
||||
$pdLog->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_manual', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: (int) $request->user()->id,
|
||||
actorAdminUserId: null, ip: $request->ip(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'deal' => [
|
||||
'id' => $deal->id,
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -56,17 +55,6 @@ class DealExportController extends Controller
|
||||
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
|
||||
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'exported',
|
||||
subjectType: 'lead',
|
||||
subjectId: null,
|
||||
purpose: 'deals_export_'.$format,
|
||||
tenantId: $tenantId,
|
||||
actorTenantUserId: (int) $request->user()->id,
|
||||
actorAdminUserId: null,
|
||||
ip: $request->ip(),
|
||||
);
|
||||
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
$headers = $format === 'xlsx'
|
||||
? [
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Pd\ImpersonationAuditService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
@@ -39,20 +39,10 @@ class ImpersonationController extends Controller
|
||||
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* SaaS-admin — кросс-тенантная зона: запросы к impersonation_tokens / tenants
|
||||
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
|
||||
* Иначе на проде (роль crm_app_user, RLS on) без выставленного GUC
|
||||
* app.current_tenant_id запрос падает SQLSTATE 42704 — у saas-admin нет
|
||||
* tenant-контекста (middleware 'tenant' на /api/admin/* не висит). На dev
|
||||
* pgsql_supplier = fallback на postgres-superuser, поведение идентично.
|
||||
*/
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** GET /api/admin/impersonation/active — активные сессии (used_at != null AND session_ended_at == null) */
|
||||
public function active(): JsonResponse
|
||||
{
|
||||
$rows = ImpersonationToken::on(self::DB_CONNECTION)
|
||||
$rows = ImpersonationToken::query()
|
||||
->whereNotNull('used_at')
|
||||
->whereNull('session_ended_at')
|
||||
->with(['tenant'])
|
||||
@@ -77,7 +67,7 @@ class ImpersonationController extends Controller
|
||||
/** GET /api/admin/impersonation/recent — последние 20 завершённых */
|
||||
public function recent(): JsonResponse
|
||||
{
|
||||
$rows = ImpersonationToken::on(self::DB_CONNECTION)
|
||||
$rows = ImpersonationToken::query()
|
||||
->whereNotNull('used_at')
|
||||
->whereNotNull('session_ended_at')
|
||||
->with(['tenant'])
|
||||
@@ -102,7 +92,7 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/init */
|
||||
public function init(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
public function init(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->input('tenant_id');
|
||||
$requestedBy = (int) $request->input('requested_by'); // TODO: $request->user()->id когда saas-admin auth готов
|
||||
@@ -115,7 +105,7 @@ class ImpersonationController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::on(self::DB_CONNECTION)->find($tenantId);
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if (! $tenant) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
@@ -123,7 +113,7 @@ class ImpersonationController extends Controller
|
||||
// 6-значный код. Числа от 100000 до 999999.
|
||||
$plainCode = (string) random_int(100_000, 999_999);
|
||||
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->create([
|
||||
$token = ImpersonationToken::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'requested_by' => $requestedBy,
|
||||
'code_hash' => Hash::make($plainCode),
|
||||
@@ -132,8 +122,6 @@ class ImpersonationController extends Controller
|
||||
'expires_at' => now()->addMinutes(self::TOKEN_TTL_MINUTES),
|
||||
]);
|
||||
|
||||
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
|
||||
|
||||
// TODO: отправить email на $tenant->contact_email с $plainCode.
|
||||
$payload = [
|
||||
'token_id' => $token->id,
|
||||
@@ -153,12 +141,12 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/verify */
|
||||
public function verify(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
public function verify(Request $request): JsonResponse
|
||||
{
|
||||
$tokenId = (int) $request->input('token_id');
|
||||
$code = $request->string('code')->toString();
|
||||
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
|
||||
$token = ImpersonationToken::find($tokenId);
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Токен не найден.'], 404);
|
||||
}
|
||||
@@ -176,13 +164,12 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
if (! Hash::check($code, $token->code_hash)) {
|
||||
// increment атомарен на уровне SQL, а isUsable() независимо гейтит
|
||||
// failed_attempts >= 5 — поэтому отдельная транзакция не нужна
|
||||
// (и ломала бы общий PDO в тестах под SharesSupplierPdo).
|
||||
$token->increment('failed_attempts');
|
||||
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
|
||||
$token->update(['invalidated_at' => now()]);
|
||||
}
|
||||
DB::transaction(function () use ($token) {
|
||||
$token->increment('failed_attempts');
|
||||
if ($token->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
|
||||
$token->update(['invalidated_at' => now()]);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Неверный код.',
|
||||
@@ -196,8 +183,6 @@ class ImpersonationController extends Controller
|
||||
'used_at' => now(),
|
||||
]);
|
||||
|
||||
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
|
||||
return response()->json([
|
||||
'token_id' => $token->id,
|
||||
'tenant_id' => $token->tenant_id,
|
||||
@@ -207,11 +192,11 @@ class ImpersonationController extends Controller
|
||||
}
|
||||
|
||||
/** POST /api/admin/impersonation/end */
|
||||
public function end(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
public function end(Request $request): JsonResponse
|
||||
{
|
||||
$tokenId = (int) $request->input('token_id');
|
||||
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($tokenId);
|
||||
$token = ImpersonationToken::find($tokenId);
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Токен не найден.'], 404);
|
||||
}
|
||||
@@ -230,8 +215,6 @@ class ImpersonationController extends Controller
|
||||
|
||||
$token->update(['session_ended_at' => now()]);
|
||||
|
||||
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
|
||||
// TODO: уведомление клиенту по email о завершении (как и в init flow).
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -22,18 +23,20 @@ class ManagerController extends Controller
|
||||
/** GET /api/managers?tenant_id={id} */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
// Go-live: tenant_id из authed-user (auth:sanctum + tenant middleware),
|
||||
// НЕ из параметра запроса — закрывает кросс-tenant утечку списка пользователей.
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$users = DB::transaction(function () use ($tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Явный where(tenant_id) — defense-in-depth поверх RLS: роли с
|
||||
// BYPASSRLS (crm_supplier_worker / dev-superuser) RLS не применяют,
|
||||
// поэтому tenant-scope нельзя оставлять только на SET LOCAL.
|
||||
return User::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->orderBy('first_name')
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
|
||||
use App\Jobs\GenerateReportJob;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -306,12 +305,12 @@ class ReportJobController extends Controller
|
||||
/**
|
||||
* DELETE /api/reports/jobs/{id} — удалить terminal job + файл.
|
||||
*/
|
||||
public function destroy(Request $request, int $id, PdAuditLogger $pdLog): JsonResponse
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id, $request, $pdLog): JsonResponse {
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$job = ReportJob::query()
|
||||
@@ -336,16 +335,6 @@ class ReportJobController extends Controller
|
||||
|
||||
if ($job->file_path !== null) {
|
||||
Storage::disk('local')->delete($job->file_path);
|
||||
$pdLog->record(
|
||||
action: 'deleted',
|
||||
subjectType: 'lead',
|
||||
subjectId: null,
|
||||
purpose: 'report_file_'.$job->id,
|
||||
tenantId: (int) $job->tenant_id,
|
||||
actorTenantUserId: (int) $user->id,
|
||||
actorAdminUserId: null,
|
||||
ip: $request->ip(),
|
||||
);
|
||||
}
|
||||
$job->delete();
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Support\WebhookUrlGuard;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
@@ -55,16 +53,6 @@ class WebhookSettingsController extends Controller
|
||||
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
|
||||
]);
|
||||
|
||||
// SSRF-гард на сохранении: не даём записать URL во внутреннюю/служебную
|
||||
// сеть — тогда любой будущий потребитель (test() + будущая outbound-доставка
|
||||
// событий) читает из БД только безопасные адреса. NB: будущая доставка
|
||||
// обязана ВДОБАВОК звать WebhookUrlGuard перед отправкой (защита от
|
||||
// DNS-rebinding: хост сохранён публичным, позже переразрешается в приватный).
|
||||
$blockReason = WebhookUrlGuard::blockReason($validated['target_url']);
|
||||
if ($blockReason !== null) {
|
||||
throw ValidationException::withMessages(['target_url' => [$blockReason]]);
|
||||
}
|
||||
|
||||
$sub = $this->currentSubscription($request);
|
||||
$plainSecret = null;
|
||||
|
||||
@@ -107,25 +95,14 @@ class WebhookSettingsController extends Controller
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
|
||||
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
|
||||
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
|
||||
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
|
||||
if ($blockReason !== null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'status' => null,
|
||||
'message' => $blockReason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$testPayload = [
|
||||
'event' => 'webhook.test',
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
'message' => 'Тестовая доставка webhook от Лидерра.',
|
||||
];
|
||||
|
||||
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
|
||||
// MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных
|
||||
// IP) — пост-MVP security-review; URL уже ограничен https:// валидацией.
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
|
||||
|
||||
@@ -29,10 +29,16 @@ class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
if (app()->environment('local', 'testing')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
// ВРЕМЕННО (тест-деплой): пропускаем при включённом флаге.
|
||||
// TODO: убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
|
||||
if (config('app.saas_admin_test_bypass') === true) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ trait HasPasswordRules
|
||||
{
|
||||
/**
|
||||
* Правила валидации поля password.
|
||||
* Используется в LoginRequest и RegisterRequest для DRY.
|
||||
* Используется в LoginRequest и RegisterStartRequest для DRY.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
+17
-4
@@ -5,24 +5,35 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Http\Requests\Auth\Concerns\HasPasswordRules;
|
||||
use App\Services\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/auth/register.
|
||||
* Валидация шага 1 регистрации (POST /api/auth/register/start).
|
||||
*
|
||||
* По ТЗ §1.5/§4.1: 2 обязательных click-wrap'а — оферта + согласие на ПДн
|
||||
* (3-й «маркетинговый» из handoff НЕ требуется — расхождение #2 реестра v1.13).
|
||||
* Телефон нормализуется в prepareForValidation() к 7XXXXXXXXXX до проверки
|
||||
* (если нормализовать нельзя — остаётся как есть и падает на regex).
|
||||
* По ТЗ §1.5/§4.1: два обязательных click-wrap'а (оферта + ПДн).
|
||||
*/
|
||||
class RegisterRequest extends FormRequest
|
||||
class RegisterStartRequest extends FormRequest
|
||||
{
|
||||
use HasPasswordRules;
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$normalized = PhoneNormalizer::normalize((string) $this->input('phone', ''));
|
||||
if ($normalized !== null) {
|
||||
$this->merge(['phone' => $normalized]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'password' => $this->passwordRules(),
|
||||
'accept_offer' => ['required', 'accepted'],
|
||||
'accept_pdn' => ['required', 'accepted'],
|
||||
@@ -36,6 +47,8 @@ class RegisterRequest extends FormRequest
|
||||
'email.required' => 'Укажите email.',
|
||||
'email.email' => 'Email указан некорректно.',
|
||||
'email.unique' => 'Аккаунт с таким email уже существует.',
|
||||
'phone.required' => 'Укажите номер телефона.',
|
||||
'phone.regex' => 'Телефон указан некорректно. Формат: +7 (XXX) XXX-XX-XX.',
|
||||
'accept_offer.accepted' => 'Необходимо принять оферту.',
|
||||
'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
|
||||
]);
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация шага 2 регистрации (POST /api/auth/register/verify).
|
||||
*/
|
||||
class RegisterVerifyRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => 'Введите код из письма.',
|
||||
'code.regex' => 'Код состоит из 6 цифр.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -156,12 +155,6 @@ class ProcessWebhookJob implements ShouldQueue
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function logRejection(Tenant $tenant, string $reason): void
|
||||
@@ -245,12 +238,6 @@ class ProcessWebhookJob implements ShouldQueue
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей
|
||||
// в БД, чтобы при ошибке отправки транзакция уже была зафиксирована.
|
||||
// NotificationService сам ловит Throwable от Mail::send и логирует —
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -92,20 +91,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
LeadDistributor $distributor,
|
||||
RegionTagResolver $tagResolver,
|
||||
): void {
|
||||
$lead = SupplierLead::find($this->supplierLeadId);
|
||||
|
||||
// Терминальный случай: лид удалён/не существует — это НЕ транзиентная ошибка,
|
||||
// повтор бессмыслен. НЕ бросаем ModelNotFoundException: иначе queue->failed()
|
||||
// пишет строку в failed_webhook_jobs, а RetryFailedSupplierJobsCommand
|
||||
// бесконечно перезапускает job (retry-шторм, инцидент 21-22.05.2026 —
|
||||
// 25k+ записей по удалённому лиду №1).
|
||||
if ($lead === null) {
|
||||
Log::warning('supplier_lead.not_found_terminal', [
|
||||
'supplier_lead_id' => $this->supplierLeadId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$lead = SupplierLead::findOrFail($this->supplierLeadId);
|
||||
|
||||
// Idempotency guard для retry-сценария ($tries = 3).
|
||||
// Если лид уже обработан — выходим, не создаём ghost duplicate'ы deal'ов.
|
||||
@@ -296,12 +282,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -324,12 +304,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
|
||||
// мог подтянуть deal->project без N+1 lookup'а под RLS.
|
||||
$deal->setRelation('project', $project);
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use App\Support\RussianRegions;
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -210,10 +211,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible);
|
||||
$order = SupplierQuotaAllocator::computeOrder($eligibleLimits);
|
||||
|
||||
// Split the group order across platforms so Σ per-platform == order. The portal does
|
||||
// NOT divide (verified live 2026-05-21) — the full order on each B = order ×N overspend.
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);
|
||||
|
||||
$workdaysUnion = [];
|
||||
foreach ($eligible as $p) {
|
||||
foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) {
|
||||
@@ -239,25 +236,24 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
// Throws propagate to handle() catch (failover-counter); rows persisted for earlier
|
||||
// platforms before a throw are recovered next run via the missing-set recovery below.
|
||||
foreach ($platforms as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
|
||||
// Upsert supplier_projects rows (one per platform)
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
@@ -269,7 +265,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -302,21 +298,23 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
);
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
foreach ($deadSps as $sp) {
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$sp->platform],
|
||||
);
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $deadPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $deadPlatforms,
|
||||
);
|
||||
|
||||
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
|
||||
$recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto);
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
if ($newId !== null) {
|
||||
$sp->forceFill(['supplier_external_id' => (string) $newId])->save();
|
||||
@@ -338,21 +336,22 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
|
||||
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
@@ -363,7 +362,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -379,9 +378,9 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
// per-platform DTO в update-loop: portal получает правильные srcrt/srcbl/srcmt для
|
||||
// конкретной строки + её долю лимита ($shares), чтобы Σ по площадкам == order
|
||||
// (а не order на каждой). Regions/workdays общие для группы.
|
||||
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal получал
|
||||
// правильные srcrt/srcbl/srcmt для конкретной редактируемой строки (не first()
|
||||
// из mixed-platform existing set). R6 one shared limit/regions сохраняется.
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
@@ -390,7 +389,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -400,7 +399,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
);
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -427,6 +426,31 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-link к корневому домену (spec 2026-05-22-root-domain-auto-link-design §4.2).
|
||||
// groupProjects шарят identifier — root один на всю группу.
|
||||
$firstProject = $groupProjects[0] ?? null;
|
||||
if ($firstProject !== null && $firstProject->signal_type === 'site') {
|
||||
$rootIdentifier = SupplierIdentifier::extractRootDomain(
|
||||
(string) $firstProject->signal_identifier
|
||||
);
|
||||
if ($rootIdentifier !== null) {
|
||||
$rootSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $rootIdentifier)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
foreach ($groupProjects as $lp) {
|
||||
foreach ($rootSps as $rootSp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $lp->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,8 @@ use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use App\Support\RussianRegions;
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -117,11 +117,6 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
|
||||
|
||||
// Split the limit across the platforms so Σ per-platform limits == project limit.
|
||||
// The portal does NOT divide (verified live 2026-05-21) — replicating the full limit
|
||||
// to B1/B2/B3 = order ×N (overspend). See SupplierQuotaAllocator::distributeForPlatform.
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform((int) $project->daily_limit_target, $platforms);
|
||||
|
||||
// Idempotency: find existing by identifier regardless of subject_code (any previous run).
|
||||
$existingSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $identifier)
|
||||
@@ -130,9 +125,35 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
$idMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$idMap = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
return;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} deferred by portal window");
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
@@ -146,7 +167,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -173,7 +194,31 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreatedIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $deadPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $deadPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$recreatedIdMap = $client->saveProjectMultiFlag($recreateDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create escalated #{$e->queueRowId}");
|
||||
$recreatedIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create deferred by portal window");
|
||||
$recreatedIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: dead-donor re-create failed for project {$project->id}: ".$e->getMessage());
|
||||
$recreatedIdMap = [];
|
||||
}
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
@@ -188,7 +233,31 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform re-attempt escalated #{$e->queueRowId}");
|
||||
$missingIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} missing-platform deferred by portal window");
|
||||
$missingIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id}: ".$e->getMessage());
|
||||
$missingIdMap = [];
|
||||
}
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
@@ -201,7 +270,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -220,7 +289,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
platform: $sp->platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -230,7 +299,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
);
|
||||
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -249,6 +318,29 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
// Auto-link к корневому домену (spec 2026-05-22-root-domain-auto-link-design §4.2).
|
||||
// Если signal_type=site и identifier — субдомен (carmoney.ru → corner; krasnoyarsk.carmoney.ru → subdomain),
|
||||
// дополнительно подключаем sp с unique_key=root, если такие существуют.
|
||||
if ($project->signal_type === 'site') {
|
||||
$rootIdentifier = SupplierIdentifier::extractRootDomain(
|
||||
(string) $project->signal_identifier
|
||||
);
|
||||
if ($rootIdentifier !== null) {
|
||||
$rootSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $rootIdentifier)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
foreach ($rootSps as $rootSp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the link into the legacy FK columns (supplier_b{1,2,3}_project_id) so the
|
||||
// UI sync-status (ProjectResource → aggregateSyncStatus, which reads supplierB1/B2/B3)
|
||||
// reflects the synced stack in online mode too — online primarily uses the pivot.
|
||||
@@ -327,68 +419,6 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$project->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт проекты на портале ПО ОДНОМУ на платформу с её долей лимита ($shares).
|
||||
*
|
||||
* Один single-flag save = ровно один rt-проект → надёжный id через listProjects-матч.
|
||||
* Так per-platform лимит = доля (Σ == заказу), а не полный лимит на каждой площадке.
|
||||
* Per-platform tolerance: tier-escalation / window-defer / прочая ошибка одной площадки
|
||||
* не валит остальные — пропускаем, следующий run (или ночной батч) подберёт недостающее.
|
||||
*
|
||||
* @param array<string, int> $shares [platform => лимит площадки]
|
||||
* @param list<string> $platformsToCreate
|
||||
* @return array<string, int> [platform => external_id] для успешно созданных
|
||||
*/
|
||||
private function createPerPlatform(
|
||||
SupplierPortalClient $client,
|
||||
Project $project,
|
||||
string $identifier,
|
||||
string $tag,
|
||||
array $workdays,
|
||||
array $allRegions,
|
||||
array $shares,
|
||||
array $platformsToCreate,
|
||||
): array {
|
||||
$idMap = [];
|
||||
|
||||
foreach ($platformsToCreate as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$platform],
|
||||
);
|
||||
|
||||
try {
|
||||
$result = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: online per-platform save failed for project {$project->id} {$platform} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($result[$platform])) {
|
||||
$idMap[$platform] = $result[$platform];
|
||||
}
|
||||
}
|
||||
|
||||
return $idMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ISO weekday list. bit 0 = Mon (ISO 1) … bit 6 = Sun (ISO 7).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо с 6-значным кодом подтверждения email при регистрации.
|
||||
*
|
||||
* На dev (MAIL_MAILER=log) — пишется в storage/logs/laravel.log.
|
||||
* На prod/pilot — Яндекс SMTP (см. app/.env MAIL_*).
|
||||
*/
|
||||
class RegisterEmailVerificationCode extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public string $code) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Код подтверждения регистрации — Лидерра',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.register_verification_code',
|
||||
with: ['code' => $this->code],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ class User extends Authenticatable
|
||||
'is_active',
|
||||
'last_login_at',
|
||||
'last_active_at',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Project;
|
||||
use App\Models\Reminder;
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
@@ -27,7 +26,6 @@ final class HistoricalImportService
|
||||
public function __construct(
|
||||
private readonly MonthlyPartitionManager $partitions,
|
||||
private readonly StatusRuToSlugMapper $statusMapper,
|
||||
private readonly PdAuditLogger $pdLog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -70,7 +68,7 @@ final class HistoricalImportService
|
||||
}
|
||||
|
||||
try {
|
||||
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug, $log->id);
|
||||
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
|
||||
$wasCreated ? $added++ : $updated++;
|
||||
} catch (Throwable $e) {
|
||||
$skipped++;
|
||||
@@ -134,9 +132,9 @@ final class HistoricalImportService
|
||||
* Идемпотентный upsert одной строки в собственной транзакции.
|
||||
* Возвращает true — создана новая сделка, false — обновлена существующая.
|
||||
*/
|
||||
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug, int $importLogId): bool
|
||||
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $userId, $row, $slug, $importLogId): bool {
|
||||
return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$project = Project::firstOrCreate(
|
||||
@@ -190,17 +188,6 @@ final class HistoricalImportService
|
||||
|
||||
$this->syncReminder($tenantId, $userId, $deal, $row);
|
||||
|
||||
$this->pdLog->record(
|
||||
action: 'created',
|
||||
subjectType: 'lead',
|
||||
subjectId: $deal->id,
|
||||
purpose: 'lead_create_import_'.$importLogId,
|
||||
tenantId: $tenantId,
|
||||
actorTenantUserId: $userId,
|
||||
actorAdminUserId: null,
|
||||
ip: null,
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pd;
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
|
||||
/**
|
||||
* Оркестратор аудита impersonation: пишет защищённый saas_admin_audit_log
|
||||
* на init/verify/end и ПДн-след (pd_processing_log) на verify — вход админа
|
||||
* в кабинет тенанта = массовый доступ к ПДн (152-ФЗ).
|
||||
*/
|
||||
final class ImpersonationAuditService
|
||||
{
|
||||
public function __construct(private readonly PdAuditLogger $pd) {}
|
||||
|
||||
public function recordInit(ImpersonationToken $t, int $adminId, ?string $ip): void
|
||||
{
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminId,
|
||||
'action' => 'impersonation.init',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $t->tenant_id,
|
||||
'target_tenant_id' => $t->tenant_id,
|
||||
'payload_before' => null,
|
||||
'payload_after' => ['token_id' => $t->id, 'expires_at' => $t->expires_at->toIso8601String()],
|
||||
'reason' => $t->reason,
|
||||
'ip_address' => $ip ?? '127.0.0.1',
|
||||
'user_agent' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordVerify(ImpersonationToken $t, int $adminId, ?string $ip): void
|
||||
{
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminId,
|
||||
'action' => 'impersonation.verify',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $t->tenant_id,
|
||||
'target_tenant_id' => $t->tenant_id,
|
||||
'payload_before' => ['used_at' => null],
|
||||
'payload_after' => ['used_at' => now()->toIso8601String()],
|
||||
'reason' => $t->reason,
|
||||
'ip_address' => $ip ?? '127.0.0.1',
|
||||
'user_agent' => null,
|
||||
]);
|
||||
// ПДн-след: вход админа в кабинет = массовый доступ к ПДн тенанта.
|
||||
$this->pd->record(
|
||||
action: 'viewed', subjectType: 'tenant', subjectId: $t->tenant_id,
|
||||
purpose: 'impersonation_session_'.$t->id,
|
||||
tenantId: $t->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: $adminId, ip: $ip,
|
||||
);
|
||||
}
|
||||
|
||||
public function recordEnd(ImpersonationToken $t, int $adminId, ?string $ip): void
|
||||
{
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminId,
|
||||
'action' => 'impersonation.end',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $t->tenant_id,
|
||||
'target_tenant_id' => $t->tenant_id,
|
||||
'payload_before' => ['session_ended_at' => null],
|
||||
'payload_after' => ['session_ended_at' => now()->toIso8601String()],
|
||||
'reason' => $t->reason,
|
||||
'ip_address' => $ip ?? '127.0.0.1',
|
||||
'user_agent' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pd;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Запись в pd_processing_log (152-ФЗ ст.18 ч.2). Hash-chain trigger
|
||||
* audit_chain_hash() автоматически заполняет log_hash; append-only
|
||||
* защита — триггер audit_block_mutation (UPDATE/DELETE заблокированы).
|
||||
*
|
||||
* chk_pd_actor: ровно один актор из tenant_user/admin, либо оба NULL
|
||||
* (системное действие — cron / триггер).
|
||||
*/
|
||||
final class PdAuditLogger
|
||||
{
|
||||
/** @param string $action one of 'created','viewed','updated','deleted','exported' */
|
||||
public function record(
|
||||
string $action,
|
||||
?string $subjectType,
|
||||
?int $subjectId,
|
||||
string $purpose,
|
||||
?int $tenantId,
|
||||
?int $actorTenantUserId,
|
||||
?int $actorAdminUserId,
|
||||
?string $ip,
|
||||
): void {
|
||||
DB::table('pd_processing_log')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'subject_type' => $subjectType,
|
||||
'subject_id' => $subjectId,
|
||||
'action' => $action,
|
||||
'purpose' => $purpose,
|
||||
'actor_tenant_user_id' => $actorTenantUserId,
|
||||
'actor_admin_user_id' => $actorAdminUserId,
|
||||
'ip_address' => $ip,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Нормализация телефонного номера РФ к каноническому виду 7XXXXXXXXXX
|
||||
* (консистентно с App\Services\PhonePrefixService, ожидающим ^7\d{10}$).
|
||||
*
|
||||
* Принимает любые человеко-вводимые формы: +7 (XXX) ..., 8 XXX ...,
|
||||
* голые 10 или 11 цифр, с пробелами/скобками/дефисами. Возвращает null,
|
||||
* если после очистки невозможно получить валидный 11-значный номер с ведущей 7.
|
||||
*/
|
||||
class PhoneNormalizer
|
||||
{
|
||||
public static function normalize(string $raw): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
|
||||
if (strlen($digits) === 11 && $digits[0] === '8') {
|
||||
$digits = '7'.substr($digits, 1);
|
||||
} elseif (strlen($digits) === 10) {
|
||||
$digits = '7'.$digits;
|
||||
}
|
||||
|
||||
return preg_match('/^7\d{10}$/', $digits) === 1 ? $digits : null;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Import;
|
||||
|
||||
/**
|
||||
* Pure-хелперы перевода полей строки rt-проекта поставщика → поля Лидерры.
|
||||
* Без побочных эффектов и зависимостей — только статические функции.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md §4
|
||||
*/
|
||||
final class SupplierImportMapper
|
||||
{
|
||||
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
|
||||
private const TYPE_TO_SIGNAL = ['calls' => 'call', 'hosts' => 'site', 'sms' => 'sms'];
|
||||
|
||||
public static function platformFromSrc(string $src): ?string
|
||||
{
|
||||
return self::SRC_TO_PLATFORM[$src] ?? null;
|
||||
}
|
||||
|
||||
public static function signalTypeFromType(string $type): ?string
|
||||
{
|
||||
return self::TYPE_TO_SIGNAL[$type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строку ГИБДД-кодов («24», «24,77», «24, 77 78») → list<int>.
|
||||
* Пусто/null → [].
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function parseGibddRegions(?string $regions): array
|
||||
{
|
||||
if ($regions === null) {
|
||||
return [];
|
||||
}
|
||||
$parts = preg_split('/[,\s]+/', trim($regions), -1, PREG_SPLIT_NO_EMPTY);
|
||||
if ($parts === false || $parts === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static fn (string $p): int => (int) $p, $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список дней-строк ["1".."7"] (1=Пн..7=Вс ISO) → битовая маска (bit0=Пн).
|
||||
* Пусто → 127 (все дни).
|
||||
*
|
||||
* @param list<int|string> $workdays
|
||||
*/
|
||||
public static function workdaysToMask(array $workdays): int
|
||||
{
|
||||
if ($workdays === []) {
|
||||
return 127;
|
||||
}
|
||||
$mask = 0;
|
||||
foreach ($workdays as $d) {
|
||||
$day = (int) $d;
|
||||
if ($day >= 1 && $day <= 7) {
|
||||
$mask |= (1 << ($day - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $mask === 0 ? 127 : $mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* sms-content: «sender+keyword» → ['sender'=>…, 'keyword'=>…];
|
||||
* «sender» (без плюса) → ['sender'=>…, 'keyword'=>null].
|
||||
*
|
||||
* @return array{sender: string, keyword: string|null}
|
||||
*/
|
||||
public static function parseSmsContent(string $content): array
|
||||
{
|
||||
$plus = strpos($content, '+');
|
||||
if ($plus === false) {
|
||||
return ['sender' => $content, 'keyword' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'sender' => substr($content, 0, $plus),
|
||||
'keyword' => substr($content, $plus + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Import;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SupplierSyncLog;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Support\SupplierRegions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Усыновление активных проектов поставщика (аккаунт lkomega) как проектов
|
||||
* Лидерры. Читает listProjects (read-only), группирует площадки B1/B2/B3 в один
|
||||
* проект, реверс-маппит регионы, считает лимит как сумму площадок.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md
|
||||
*/
|
||||
class SupplierProjectImporter
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public function __construct(
|
||||
private readonly SupplierPortalClient $client,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>}
|
||||
*/
|
||||
public function buildPlan(int $tenantId): array
|
||||
{
|
||||
$rows = $this->client->listProjects();
|
||||
|
||||
/** @var list<array{reason: string, label: string}> $skipped */
|
||||
$skipped = [];
|
||||
|
||||
/** @var array<string, array<string, mixed>> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (($row['status'] ?? false) !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$platform = SupplierImportMapper::platformFromSrc((string) ($row['src'] ?? ''));
|
||||
if ($platform === null) {
|
||||
$skipped[] = ['reason' => 'unsupported_source', 'label' => (string) ($row['name'] ?? $row['content'] ?? '?')];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$signalType = SupplierImportMapper::signalTypeFromType((string) ($row['type'] ?? ''));
|
||||
if ($signalType === null) {
|
||||
$skipped[] = ['reason' => 'unsupported_type', 'label' => (string) ($row['name'] ?? '?')];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($signalType === 'sms') {
|
||||
$parsed = SupplierImportMapper::parseSmsContent((string) ($row['content'] ?? ''));
|
||||
$sender = $parsed['sender'];
|
||||
if ($sender === '') {
|
||||
$skipped[] = ['reason' => 'sms_unparseable', 'label' => (string) ($row['name'] ?? '?')];
|
||||
|
||||
continue;
|
||||
}
|
||||
$key = 'sms|'.$sender;
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => [$sender],
|
||||
'sms_keyword' => null,
|
||||
'tag' => '',
|
||||
'regions' => [],
|
||||
'has_all_russia' => false,
|
||||
'workdays_mask' => 0,
|
||||
'daily_limit_target' => 0,
|
||||
'platforms' => [],
|
||||
];
|
||||
}
|
||||
if ($parsed['keyword'] !== null && $parsed['keyword'] !== '' && $groups[$key]['sms_keyword'] === null) {
|
||||
$groups[$key]['sms_keyword'] = $parsed['keyword'];
|
||||
}
|
||||
if (($row['regions_reverse'] ?? false) === true) {
|
||||
$skipped[] = ['reason' => 'regions_exclude', 'label' => $sender];
|
||||
$groups[$key]['__excluded'] = true;
|
||||
}
|
||||
$this->accumulateRow($groups[$key], $row, $platform);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = (string) ($row['content'] ?? '');
|
||||
$key = $signalType.'|'.$identifier;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => $signalType,
|
||||
'signal_identifier' => $identifier,
|
||||
'sms_senders' => [],
|
||||
'sms_keyword' => null,
|
||||
'tag' => '',
|
||||
'regions' => [],
|
||||
'has_all_russia' => false,
|
||||
'workdays_mask' => 0,
|
||||
'daily_limit_target' => 0,
|
||||
'platforms' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if (($row['regions_reverse'] ?? false) === true) {
|
||||
$skipped[] = ['reason' => 'regions_exclude', 'label' => $identifier];
|
||||
$groups[$key]['__excluded'] = true;
|
||||
}
|
||||
$this->accumulateRow($groups[$key], $row, $platform);
|
||||
}
|
||||
|
||||
$planned = [];
|
||||
foreach ($groups as $g) {
|
||||
if (($g['__excluded'] ?? false) === true) {
|
||||
continue;
|
||||
}
|
||||
unset($g['__excluded']);
|
||||
unset($g['has_all_russia']);
|
||||
$g['delivery_days_mask'] = $g['workdays_mask'] === 0 ? 127 : $g['workdays_mask'];
|
||||
unset($g['workdays_mask']);
|
||||
if ($g['tag'] === '') {
|
||||
$g['tag'] = 'РФ';
|
||||
}
|
||||
$g['name'] = $this->deriveName($g);
|
||||
|
||||
if ($this->projectExists($tenantId, $g)) {
|
||||
$skipped[] = ['reason' => 'already_exists', 'label' => $this->groupLabel($g)];
|
||||
|
||||
continue;
|
||||
}
|
||||
$planned[] = $g;
|
||||
}
|
||||
|
||||
return ['planned' => $planned, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Пишет план в БД: Project + supplier_projects (external_id с портала) + pivot.
|
||||
* НЕ обращается к порталу. Каждый проект — в своей транзакции.
|
||||
*
|
||||
* @param array{planned: list<array<string, mixed>>, skipped: list<array{reason: string, label: string}>} $plan
|
||||
* @return array{created_projects: int, created_supplier_projects: int, created_links: int}
|
||||
*/
|
||||
public function commit(array $plan, int $tenantId): array
|
||||
{
|
||||
$createdProjects = 0;
|
||||
$createdSps = 0;
|
||||
$createdLinks = 0;
|
||||
|
||||
$conn = DB::connection(self::DB_CONNECTION);
|
||||
|
||||
foreach ($plan['planned'] as $item) {
|
||||
$writeItem = function () use ($item, $tenantId, &$createdProjects, &$createdSps, &$createdLinks): void {
|
||||
/** @var Project $project */
|
||||
$project = Project::on(self::DB_CONNECTION)->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $item['name'],
|
||||
'tag' => $item['tag'],
|
||||
'is_active' => true,
|
||||
'signal_type' => $item['signal_type'],
|
||||
'signal_identifier' => $item['signal_identifier'],
|
||||
'sms_senders' => $item['sms_senders'] !== [] ? $item['sms_senders'] : null,
|
||||
'sms_keyword' => $item['sms_keyword'],
|
||||
'regions' => $item['regions'],
|
||||
'region_mode' => 'include',
|
||||
'delivery_days_mask' => $item['delivery_days_mask'],
|
||||
'daily_limit_target' => $item['daily_limit_target'],
|
||||
]);
|
||||
$createdProjects++;
|
||||
|
||||
foreach ($item['platforms'] as $pl) {
|
||||
$platform = (string) $pl['platform'];
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
|
||||
|
||||
/** @var SupplierProject $sp */
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->firstOrCreate(
|
||||
['platform' => $platform, 'unique_key' => $uniqueKey, 'subject_code' => null],
|
||||
[
|
||||
'signal_type' => $item['signal_type'],
|
||||
'supplier_external_id' => (string) $pl['external_id'],
|
||||
'current_limit' => (int) $pl['lim'],
|
||||
'current_workdays' => $this->maskToList((int) $item['delivery_days_mask']),
|
||||
'current_regions' => $item['regions'],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
],
|
||||
);
|
||||
if ($sp->wasRecentlyCreated) {
|
||||
$createdSps++;
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$inserted = DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
$createdLinks += $inserted;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-project atomicity (spec §8): сбой посреди группы не должен оставить
|
||||
// orphan-Project без supplier_projects/pivot. В проде оборачиваем в транзакцию.
|
||||
// Под тестовым харнессом (SharesSupplierPdo + DatabaseTransactions) общий PDO
|
||||
// уже в транзакции — повторный BEGIN бросил бы «already active», поэтому пишем
|
||||
// напрямую (внешняя транзакция теста сама откатится).
|
||||
if ($conn->getPdo()->inTransaction()) {
|
||||
$writeItem();
|
||||
} else {
|
||||
$conn->transaction($writeItem);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'created_projects' => $createdProjects,
|
||||
'created_supplier_projects' => $createdSps,
|
||||
'created_links' => $createdLinks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Маска дней (bit0=Пн) → list<int> [1..7].
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function maskToList(int $mask): array
|
||||
{
|
||||
$out = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
if (($mask & (1 << $i)) !== 0) {
|
||||
$out[] = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function accumulateRow(array &$group, array $row, string $platform): void
|
||||
{
|
||||
$lim = (int) ($row['lim'] ?? 0);
|
||||
$group['daily_limit_target'] += $lim;
|
||||
$group['platforms'][] = [
|
||||
'platform' => $platform,
|
||||
'external_id' => (int) ($row['id'] ?? 0),
|
||||
'lim' => $lim,
|
||||
];
|
||||
|
||||
$rowTag = trim((string) ($row['tag'] ?? ''));
|
||||
if ($group['tag'] === '' && $rowTag !== '' && $rowTag !== 'РФ') {
|
||||
$group['tag'] = $rowTag;
|
||||
}
|
||||
|
||||
$group['workdays_mask'] |= SupplierImportMapper::workdaysToMask((array) ($row['workdays'] ?? []));
|
||||
|
||||
if (! $group['has_all_russia']) {
|
||||
$gibdd = SupplierImportMapper::parseGibddRegions(
|
||||
is_string($row['regions'] ?? null) ? $row['regions'] : ''
|
||||
);
|
||||
if ($gibdd === []) {
|
||||
$group['has_all_russia'] = true;
|
||||
$group['regions'] = [];
|
||||
} else {
|
||||
$liderra = SupplierRegions::mapFromSupplier($gibdd);
|
||||
$group['regions'] = array_values(array_unique(array_merge($group['regions'], $liderra)));
|
||||
sort($group['regions']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
private function projectExists(int $tenantId, array $group): bool
|
||||
{
|
||||
$query = Project::on('pgsql_supplier')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('signal_type', $group['signal_type']);
|
||||
|
||||
if ($group['signal_type'] === 'sms') {
|
||||
$sender = $group['sms_senders'][0] ?? '';
|
||||
$keyword = $group['sms_keyword'];
|
||||
|
||||
return $query
|
||||
->whereJsonContains('sms_senders', $sender)
|
||||
->where(fn ($q) => $keyword === null ? $q->whereNull('sms_keyword') : $q->where('sms_keyword', $keyword))
|
||||
->exists();
|
||||
}
|
||||
|
||||
return $query->where('signal_identifier', $group['signal_identifier'])->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
private function groupLabel(array $group): string
|
||||
{
|
||||
return $group['signal_type'] === 'sms'
|
||||
? (string) ($group['sms_senders'][0] ?? '?')
|
||||
: (string) ($group['signal_identifier'] ?? '?');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
private function deriveName(array $group): string
|
||||
{
|
||||
$tag = trim((string) $group['tag']);
|
||||
$identifier = $group['signal_type'] === 'sms'
|
||||
? (string) ($group['sms_senders'][0] ?? '')
|
||||
: (string) ($group['signal_identifier'] ?? '');
|
||||
|
||||
// projects has UNIQUE(tenant_id, name): несколько групп с одинаковым тегом
|
||||
// («КРК» приходит на десятки разных телефонов) обязаны иметь разные имена.
|
||||
// Поэтому комбинируем тег + идентификатор. «РФ» — placeholder тега, не часть имени.
|
||||
$tagPart = ($tag !== '' && $tag !== 'РФ') ? $tag : '';
|
||||
if ($tagPart !== '' && $identifier !== '') {
|
||||
$name = $tagPart.' · '.$identifier;
|
||||
} elseif ($tagPart !== '') {
|
||||
$name = $tagPart;
|
||||
} elseif ($identifier !== '') {
|
||||
$name = $identifier;
|
||||
} else {
|
||||
$name = 'проект';
|
||||
}
|
||||
|
||||
return mb_substr($name, 0, 255);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use App\Exceptions\Supplier\SupplierAuthException;
|
||||
|
||||
class PlaywrightBridge
|
||||
{
|
||||
private const TIMEOUT_SECONDS = 75; // 60s Node timeout + 15s safety buffer
|
||||
private const TIMEOUT_SECONDS = 180; // 60s Node timeout + запас на холодный старт Chromium на маломощных VM (тест-сервер YC 2vCPU/2GB: ~65s wall-clock на refresh-session). До 21.05.2026 было 75с — упиралось на тест-сервере.
|
||||
|
||||
private const SCRIPT_RELATIVE_PATH = 'playwright/refresh-session.js';
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Support\SupplierRegions;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
@@ -478,10 +477,7 @@ class SupplierPortalClient
|
||||
'srcseg' => false,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $workdays,
|
||||
// DTO несёт Лидерра-коды (конституционный порядок); поставщик ждёт
|
||||
// свои коды (ГИБДД). Без перевода уходил чужой регион (Красноярский 29
|
||||
// → Архангельск 29). См. App\Support\SupplierRegions.
|
||||
'regions' => SupplierRegions::mapToSupplier($dto->regions),
|
||||
'regions' => $dto->regions,
|
||||
'regions_reverse' => $dto->regionsReverse,
|
||||
'status' => $dto->status === 'active',
|
||||
'show' => true,
|
||||
|
||||
@@ -11,19 +11,14 @@ use Illuminate\Support\Collection;
|
||||
/**
|
||||
* Pure function: формула заказа у поставщика на (источник × субъект).
|
||||
*
|
||||
* Заказ группы eligible-клиентов:
|
||||
* Эпик миграции проектов (Plan 3): platform-split B1/B2/B3 удалён — портал
|
||||
* делит лимит сам (R6). Один лимит на группу eligible-клиентов:
|
||||
*
|
||||
* order = max(наибольший_лимит, ceil(Σ_лимитов / 3))
|
||||
*
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз клиентам Лидерры).
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Этот `order` затем ДЕЛИТСЯ между площадками B1/B2/B3 через distributeForPlatform()
|
||||
* так, чтобы Σ per-platform лимитов == order. Портал НЕ делит сам: проверено вживую
|
||||
* 2026-05-21 (listProjects) — каждый B-проект честно набирает до своего лимита
|
||||
* независимо, поэтому одинаковый лимит на 3 площадках = заказ ×3 (переплата).
|
||||
* Plan 3 R6 («портал делит, verified 15→5») оказался ложным — split восстановлен.
|
||||
*
|
||||
* `allocate()` оставлен с прежней сигнатурой для временной совместимости
|
||||
* c SyncSupplierProjectsJob — внутри использует computeOrder, возвращает
|
||||
* DTO с одинаковым limit на любую platform/signalType.
|
||||
@@ -81,7 +76,7 @@ final class SupplierQuotaAllocator
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Возвращает заказ ГРУППЫ; деление между B1/B2/B3 — distributeForPlatform().
|
||||
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 — наш split убран).
|
||||
*
|
||||
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
|
||||
*/
|
||||
@@ -97,40 +92,6 @@ final class SupplierQuotaAllocator
|
||||
return max($max, (int) ceil($sum / 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Делит групповой заказ между площадками так, чтобы СУММА per-platform лимитов == order.
|
||||
*
|
||||
* Largest-remainder: каждой площадке floor(order/N), затем по +1 первым (order mod N)
|
||||
* площадкам в порядке списка. Сумма всегда точно равна order — ни переплаты, ни недобора.
|
||||
*
|
||||
* Восстанавливает поведение, удалённое в Plan 3 R6 (ошибочное допущение «портал делит сам»).
|
||||
* Портал НЕ делит — каждый B-проект набирает до своего лимита независимо; одинаковый
|
||||
* лимит на N площадках = заказ ×N (переплата). Verified live 2026-05-21.
|
||||
*
|
||||
* @param list<string> $platforms площадки в каноническом порядке (B1<B2<B3)
|
||||
* @return array<string, int> [platform => лимит этой площадки]
|
||||
*/
|
||||
public static function distributeForPlatform(int $order, array $platforms): array
|
||||
{
|
||||
$count = count($platforms);
|
||||
if ($count === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$order = max(0, $order);
|
||||
$base = intdiv($order, $count);
|
||||
$remainder = $order % $count;
|
||||
|
||||
$shares = [];
|
||||
$i = 0;
|
||||
foreach ($platforms as $platform) {
|
||||
$shares[$platform] = $base + ($i < $remainder ? 1 : 0);
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $arrays
|
||||
* @return array<int, int>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Утилиты для работы с identifier'ами поставщика (supplier_projects.unique_key).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1
|
||||
*/
|
||||
class SupplierIdentifier
|
||||
{
|
||||
/**
|
||||
* Извлекает корневой домен из identifier'а проекта.
|
||||
*
|
||||
* Правило: если identifier выглядит как домен с ≥3 сегментами через точку —
|
||||
* вернуть последние 2 сегмента. Иначе (уже корень, телефон, sms-ключ) — null.
|
||||
*
|
||||
* Public-suffix-list (.co.uk и т.п.) НЕ поддерживается — у проекта только
|
||||
* .ru/.рф/.com, для которых правило «2 последних сегмента» корректно.
|
||||
*/
|
||||
public static function extractRootDomain(string $identifier): ?string
|
||||
{
|
||||
$trimmed = trim($identifier);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
if (! str_contains($trimmed, '.')) {
|
||||
return null;
|
||||
}
|
||||
$parts = explode('.', $trimmed);
|
||||
if (count($parts) < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode('.', array_slice($parts, -2));
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Перевод кодов регионов: Лидерра → поставщик crm.bp-gr.ru.
|
||||
*
|
||||
* Лидерра нумерует субъекты РФ по конституционному порядку (ст. 65), 1..89 —
|
||||
* см. {@see RussianRegions}: Красноярский край = 29, Архангельская обл. = 35.
|
||||
* Поставщик нумерует по автомобильным кодам (ГИБДД): Красноярский = 24,
|
||||
* Архангельская = 29. Без перевода Sync отправлял Лидерра-код «как есть»
|
||||
* (`regions => [29]` для Красноярского), а поставщик понимал его как СВОЙ № 29 =
|
||||
* Архангельск → у поставщика выбирался ЧУЖОЙ регион. На dev не всплывало —
|
||||
* проверяли на «вся РФ» (пустой regions).
|
||||
*
|
||||
* Карта построена сверкой имён {@see RussianRegions::CODE_TO_NAME} ↔ live-дерево
|
||||
* регионов формы «Добавить проект» поставщика (recon 2026-05-21: node-key="id",
|
||||
* 79 субъектов-листьев). Все 79 кодов поставщика покрыты (биекция на 79).
|
||||
*
|
||||
* 10 субъектов Лидерры поставщик НЕ предлагает (нет в дереве) — их коды
|
||||
* отбрасываются при переводе (с warning'ом): Московская обл. (56),
|
||||
* Ленинградская обл. (53), Крым (13), Севастополь (84), ДНР (6), ЛНР (14),
|
||||
* Запорожская (43), Херсонская (79), Ненецкий АО (86), Ямало-Ненецкий АО (89).
|
||||
* Если у проекта это был ЕДИНСТВЕННЫЙ регион — у поставщика проект окажется без
|
||||
* георфильтра (вся РФ). Это ограничение покрытия поставщика, не баг перевода.
|
||||
*/
|
||||
final class SupplierRegions
|
||||
{
|
||||
/**
|
||||
* Лидерра-код (конституционный 1..89) => код поставщика (ГИБДД).
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public const LIDERRA_TO_SUPPLIER = [
|
||||
// Республики
|
||||
1 => 1, // Республика Адыгея
|
||||
2 => 4, // Республика Алтай
|
||||
3 => 2, // Республика Башкортостан
|
||||
4 => 3, // Республика Бурятия
|
||||
5 => 5, // Республика Дагестан
|
||||
7 => 6, // Республика Ингушетия
|
||||
8 => 7, // Кабардино-Балкарская Республика
|
||||
9 => 8, // Республика Калмыкия
|
||||
10 => 9, // Карачаево-Черкесская Республика
|
||||
11 => 10, // Республика Карелия
|
||||
12 => 11, // Республика Коми
|
||||
15 => 12, // Республика Марий Эл
|
||||
16 => 13, // Республика Мордовия
|
||||
17 => 14, // Республика Саха (Якутия)
|
||||
18 => 15, // Республика Северная Осетия — Алания
|
||||
19 => 16, // Республика Татарстан
|
||||
20 => 17, // Республика Тыва
|
||||
21 => 18, // Удмуртская Республика
|
||||
22 => 19, // Республика Хакасия
|
||||
23 => 20, // Чеченская Республика
|
||||
24 => 21, // Чувашская Республика
|
||||
// Края
|
||||
25 => 22, // Алтайский край
|
||||
26 => 75, // Забайкальский край
|
||||
27 => 41, // Камчатский край
|
||||
28 => 23, // Краснодарский край
|
||||
29 => 24, // Красноярский край
|
||||
30 => 59, // Пермский край
|
||||
31 => 25, // Приморский край
|
||||
32 => 26, // Ставропольский край
|
||||
33 => 27, // Хабаровский край
|
||||
// Области
|
||||
34 => 28, // Амурская область
|
||||
35 => 29, // Архангельская область
|
||||
36 => 30, // Астраханская область
|
||||
37 => 31, // Белгородская область
|
||||
38 => 32, // Брянская область
|
||||
39 => 33, // Владимирская область
|
||||
40 => 34, // Волгоградская область
|
||||
41 => 35, // Вологодская область
|
||||
42 => 36, // Воронежская область
|
||||
44 => 37, // Ивановская область
|
||||
45 => 38, // Иркутская область
|
||||
46 => 39, // Калининградская область
|
||||
47 => 40, // Калужская область
|
||||
48 => 42, // Кемеровская область
|
||||
49 => 43, // Кировская область
|
||||
50 => 44, // Костромская область
|
||||
51 => 45, // Курганская область
|
||||
52 => 46, // Курская область
|
||||
54 => 48, // Липецкая область
|
||||
55 => 49, // Магаданская область
|
||||
57 => 51, // Мурманская область
|
||||
58 => 52, // Нижегородская область
|
||||
59 => 53, // Новгородская область
|
||||
60 => 54, // Новосибирская область
|
||||
61 => 55, // Омская область
|
||||
62 => 56, // Оренбургская область
|
||||
63 => 57, // Орловская область
|
||||
64 => 58, // Пензенская область
|
||||
65 => 60, // Псковская область
|
||||
66 => 61, // Ростовская область
|
||||
67 => 62, // Рязанская область
|
||||
68 => 63, // Самарская область
|
||||
69 => 64, // Саратовская область
|
||||
70 => 65, // Сахалинская область
|
||||
71 => 66, // Свердловская область
|
||||
72 => 67, // Смоленская область
|
||||
73 => 68, // Тамбовская область
|
||||
74 => 69, // Тверская область
|
||||
75 => 70, // Томская область
|
||||
76 => 71, // Тульская область
|
||||
77 => 72, // Тюменская область
|
||||
78 => 73, // Ульяновская область
|
||||
80 => 74, // Челябинская область
|
||||
81 => 76, // Ярославская область
|
||||
// Города федерального значения
|
||||
82 => 77, // Москва
|
||||
83 => 78, // Санкт-Петербург
|
||||
// Автономная область / округа
|
||||
85 => 79, // Еврейская автономная область
|
||||
87 => 86, // Ханты-Мансийский автономный округ — Югра
|
||||
88 => 87, // Чукотский автономный округ
|
||||
];
|
||||
|
||||
/**
|
||||
* Переводит Лидерра-коды регионов в коды поставщика. Неизвестные (нет у
|
||||
* поставщика) отбрасываются с warning'ом; sentinel 0 («Вся РФ») игнорируется.
|
||||
* Результат — уникальные коды поставщика по возрастанию.
|
||||
*
|
||||
* @param list<int>|array<int|string, int|string> $liderraCodes
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function mapToSupplier(array $liderraCodes): array
|
||||
{
|
||||
$out = [];
|
||||
$dropped = [];
|
||||
|
||||
foreach ($liderraCodes as $code) {
|
||||
$code = (int) $code;
|
||||
if ($code === 0) {
|
||||
continue; // sentinel «Вся РФ»
|
||||
}
|
||||
if (isset(self::LIDERRA_TO_SUPPLIER[$code])) {
|
||||
$out[self::LIDERRA_TO_SUPPLIER[$code]] = true;
|
||||
} else {
|
||||
$dropped[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dropped !== []) {
|
||||
Log::warning('supplier.regions.unmapped', [
|
||||
'liderra_codes' => $dropped,
|
||||
'note' => 'supplier does not offer these subjects — geo-filter dropped for them',
|
||||
]);
|
||||
}
|
||||
|
||||
$codes = array_keys($out);
|
||||
sort($codes);
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инверсия {@see mapToSupplier}: коды поставщика (ГИБДД) → Лидерра-коды
|
||||
* (конституционный порядок). Неизвестные коды поставщика отбрасываются
|
||||
* с warning'ом. Результат — уникальные Лидерра-коды по возрастанию.
|
||||
*
|
||||
* @param list<int>|array<int|string, int|string> $supplierCodes
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function mapFromSupplier(array $supplierCodes): array
|
||||
{
|
||||
/** @var array<int, int> $supplierToLiderra */
|
||||
$supplierToLiderra = array_flip(self::LIDERRA_TO_SUPPLIER);
|
||||
|
||||
$out = [];
|
||||
$dropped = [];
|
||||
|
||||
foreach ($supplierCodes as $code) {
|
||||
$code = (int) $code;
|
||||
if (isset($supplierToLiderra[$code])) {
|
||||
$out[$supplierToLiderra[$code]] = true;
|
||||
} else {
|
||||
$dropped[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dropped !== []) {
|
||||
Log::warning('supplier.regions.unmapped_reverse', [
|
||||
'supplier_codes' => $dropped,
|
||||
'note' => 'supplier code has no Liderra equivalent — dropped on import',
|
||||
]);
|
||||
}
|
||||
|
||||
$codes = array_keys($out);
|
||||
sort($codes);
|
||||
|
||||
return $codes;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* SSRF-гард для исходящих webhook-URL.
|
||||
*
|
||||
* Webhook target_url задаёт авторизованный админ тенанта. Без проверки он может
|
||||
* указать внутренний адрес (`https://169.254.169.254/` cloud-metadata,
|
||||
* `https://127.0.0.1/`, `https://10.0.0.0/8`) и через кнопку «тест» получить
|
||||
* ответ внутренней службы (SSRF + info-leak). starts_with:https:// этого не ловит.
|
||||
*
|
||||
* Политика: блокируем, только если хост РАЗРЕШАЕТСЯ в приватный/зарезервированный
|
||||
* IP. Неразрешимый хост (NXDOMAIN) — не SSRF-вектор, пропускаем (реальный запрос
|
||||
* упадёт сам). Проверяются все A/AAAA-записи (защита от hostname→private).
|
||||
*/
|
||||
final class WebhookUrlGuard
|
||||
{
|
||||
/**
|
||||
* @return string|null Причина блокировки (человекочитаемая) или null, если адрес безопасен.
|
||||
*/
|
||||
public static function blockReason(string $url): ?string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return 'Некорректный URL webhook.';
|
||||
}
|
||||
$host = trim($host, '[]'); // снять скобки IPv6-литерала
|
||||
|
||||
foreach (self::resolve($host) as $ip) {
|
||||
if (! self::isPublicIp($ip)) {
|
||||
return 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<string> Все IP, в которые разрешается хост (пусто, если не разрешается). */
|
||||
private static function resolve(string $host): array
|
||||
{
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return [$host]; // IP-литерал — без DNS
|
||||
}
|
||||
|
||||
$ips = [];
|
||||
$v4 = gethostbynamel($host);
|
||||
if (is_array($v4)) {
|
||||
$ips = array_merge($ips, $v4);
|
||||
}
|
||||
$aaaa = @dns_get_record($host, DNS_AAAA);
|
||||
if (is_array($aaaa)) {
|
||||
foreach ($aaaa as $rec) {
|
||||
if (isset($rec['ipv6']) && is_string($rec['ipv6'])) {
|
||||
$ips[] = $rec['ipv6'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
private static function isPublicIp(string $ip): bool
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||
return filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false;
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||
$lower = strtolower($ip);
|
||||
// loopback / unspecified
|
||||
if ($lower === '::1' || $lower === '::') {
|
||||
return false;
|
||||
}
|
||||
// link-local fe80::/10
|
||||
if (preg_match('/^fe[89ab]/', $lower) === 1) {
|
||||
return false;
|
||||
}
|
||||
// unique-local fc00::/7
|
||||
if ($lower[0] === 'f' && in_array($lower[1], ['c', 'd'], true)) {
|
||||
return false;
|
||||
}
|
||||
// IPv4-mapped ::ffff:a.b.c.d — проверить встроенный IPv4
|
||||
if (str_contains($lower, '::ffff:')) {
|
||||
$v4 = substr($lower, (int) strrpos($lower, ':') + 1);
|
||||
if (filter_var($v4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||
return self::isPublicIp($v4);
|
||||
}
|
||||
}
|
||||
|
||||
return filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ return [
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
| ВРЕМЕННО (тест-деплой): пропуск гейта SaaS-admin зоны вне local/testing.
|
||||
| По умолчанию false → прод не затронут. Включается только на тест-сервере
|
||||
| (SAAS_ADMIN_TEST_BYPASS=true). Убрать после внедрения Yandex 360 SSO (Б-1 + DO-4).
|
||||
*/
|
||||
'saas_admin_test_bypass' => (bool) env('SAAS_ADMIN_TEST_BYPASS', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ deptrac:
|
||||
Job: [Service, Model, Repository, Mail, Exception]
|
||||
Console: [Service, Model, Repository, Job, Mail, Exception]
|
||||
Repository: [Model, Exception]
|
||||
Request: [Rule, Model]
|
||||
Request: [Rule, Model, Service]
|
||||
Resource: [Model]
|
||||
Rule: [Model]
|
||||
Mail: [Model]
|
||||
|
||||
+64
-28
@@ -627,7 +627,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 9
|
||||
count: 8
|
||||
path: tests/Feature/Auth/AuthControllerTest.php
|
||||
|
||||
-
|
||||
@@ -639,7 +639,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 14
|
||||
count: 10
|
||||
path: tests/Feature/Auth/AuthControllerTest.php
|
||||
|
||||
-
|
||||
@@ -738,6 +738,42 @@ parameters:
|
||||
count: 5
|
||||
path: tests/Feature/Auth/RecoveryCodeTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertAuthenticatedAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 17
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:travel\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Variable \$this in PHPDoc tag @var does not match assigned variable \$payload\.$#'
|
||||
identifier: varTag.differentVariable
|
||||
count: 1
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1347,7 +1383,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
count: 2
|
||||
path: tests/Feature/ImpersonationTest.php
|
||||
|
||||
-
|
||||
@@ -1629,7 +1665,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
count: 8
|
||||
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
@@ -1656,6 +1692,24 @@ parameters:
|
||||
count: 14
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1920,6 +1974,12 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1938,12 +1998,6 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2111,21 +2165,3 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
@@ -45,22 +45,40 @@ export interface LoginResponse {
|
||||
requires_2fa: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
export interface RegisterStartPayload {
|
||||
email: string;
|
||||
phone: string; // нормализованные цифры 7XXXXXXXXXX
|
||||
password: string;
|
||||
accept_offer: boolean;
|
||||
accept_pdn: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterStartResponse {
|
||||
message: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function login(payload: LoginPayload): Promise<LoginResponse> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<LoginResponse>('/api/auth/login', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function register(payload: RegisterPayload): Promise<LoginResponse> {
|
||||
export async function registerStart(payload: RegisterStartPayload): Promise<RegisterStartResponse> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<LoginResponse>('/api/auth/register', payload);
|
||||
const { data } = await apiClient.post<RegisterStartResponse>('/api/auth/register/start', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function registerVerify(code: string): Promise<LoginResponse> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<LoginResponse>('/api/auth/register/verify', { code });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function registerResend(): Promise<{ message: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ message: string }>('/api/auth/register/resend');
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import * as authApi from '../api/auth';
|
||||
import type { AuthUser, LoginPayload, RegisterPayload, ResetPasswordPayload } from '../api/auth';
|
||||
import type { AuthUser, LoginPayload, RegisterStartPayload, ResetPasswordPayload } from '../api/auth';
|
||||
import { extractRateLimitRetry } from '../api/client';
|
||||
|
||||
/**
|
||||
@@ -53,18 +53,36 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function register(payload: RegisterPayload) {
|
||||
async function registerStart(payload: RegisterStartPayload) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await authApi.register(payload);
|
||||
return await authApi.registerStart(payload);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerVerify(code: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await authApi.registerVerify(code);
|
||||
user.value = response.user;
|
||||
requires2fa.value = response.requires_2fa;
|
||||
requires2fa.value = false;
|
||||
return response;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerResend() {
|
||||
loading.value = true;
|
||||
try {
|
||||
return await authApi.registerResend();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPasswordReset(email: string) {
|
||||
loading.value = true;
|
||||
lockoutSeconds.value = null;
|
||||
@@ -160,7 +178,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
lockoutSeconds,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
registerStart,
|
||||
registerVerify,
|
||||
registerResend,
|
||||
verifyTwoFactor,
|
||||
useRecoveryCode,
|
||||
requestPasswordReset,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Утилиты телефона РФ для формы регистрации.
|
||||
* Хранение/отправка — нормализованные цифры 7XXXXXXXXXX (как на backend).
|
||||
* Отображение — маска +7 (XXX) XXX-XX-XX.
|
||||
*/
|
||||
|
||||
/** Извлекает цифры и нормализует к 7XXXXXXXXXX (макс. 11 цифр). */
|
||||
export function phoneDigits(raw: string): string {
|
||||
let d = raw.replace(/\D+/g, '');
|
||||
if (d.length === 0) return '';
|
||||
if (d[0] === '8') d = '7' + d.slice(1);
|
||||
if (d[0] !== '7') d = '7' + d;
|
||||
return d.slice(0, 11);
|
||||
}
|
||||
|
||||
/** Прогрессивная маска: '' → '', '7912' → '+7 (912', полный → '+7 (NNN) NNN-NN-NN'. */
|
||||
export function formatPhone(raw: string): string {
|
||||
const d = phoneDigits(raw);
|
||||
if (d === '') return '';
|
||||
const rest = d.slice(1); // до 10 цифр после ведущей 7
|
||||
let out = '+7';
|
||||
if (rest.length > 0) out += ' (' + rest.slice(0, 3);
|
||||
if (rest.length > 3) out += ') ' + rest.slice(3, 6);
|
||||
if (rest.length > 6) out += '-' + rest.slice(6, 8);
|
||||
if (rest.length > 8) out += '-' + rest.slice(8, 10);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Полный валидный RU-номер? */
|
||||
export function isValidPhone(raw: string): boolean {
|
||||
return /^7\d{10}$/.test(phoneDigits(raw));
|
||||
}
|
||||
@@ -100,6 +100,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="pageCount > 1" class="projects-pagination mt-4">
|
||||
<v-pagination
|
||||
v-model="store.filters.page"
|
||||
:length="pageCount"
|
||||
:total-visible="7"
|
||||
density="comfortable"
|
||||
data-testid="projects-pagination"
|
||||
@update:model-value="store.fetch()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BulkActionsBar v-if="store.selectedIds.size >= 2" />
|
||||
|
||||
<ProjectDetailsDrawer
|
||||
@@ -135,6 +146,10 @@ function dismissCutoffBanner(): void {
|
||||
localStorage.setItem(CUTOFF_BANNER_KEY, '1');
|
||||
}
|
||||
|
||||
const pageCount = computed<number>(() =>
|
||||
Math.max(1, Math.ceil(store.total / store.filters.per_page)),
|
||||
);
|
||||
|
||||
const singleSelectedProject = computed<Project | null>(() => {
|
||||
if (store.selectedIds.size !== 1) return null;
|
||||
const [id] = store.selectedIds;
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Экран регистрации (RegisterView).
|
||||
* Экран регистрации (RegisterView) — двухшаговый.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html секция #form-register.
|
||||
* Источник логики: ТЗ v8.5 §1.5/§4.1 — два обязательных click-wrap'а
|
||||
* (оферта + согласие на ПДн). 3-й «маркетинговый» click-wrap из handoff
|
||||
* НЕ реализован (handoff противоречит ТЗ — расхождение #2 из реестра v1.13).
|
||||
*
|
||||
* MVP: фронт-форма без backend submit. POST /register будет в отдельном коммите.
|
||||
* Шаг 1 (форма): email + телефон (маска) + пароль + 2 click-wrap'а (оферта/ПДн).
|
||||
* Шаг 2 (код): 6-значный код с email → создание аккаунта.
|
||||
* Источник логики: docs/superpowers/specs/2026-05-21-registration-email-verification-phone-design.md
|
||||
*/
|
||||
import { extractValidationErrors } from '../../api/client';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { computed, ref } from 'vue';
|
||||
import { formatPhone, phoneDigits } from '../../utils/phone';
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const phoneRaw = ref(''); // нормализованные цифры 7XXXXXXXXXX
|
||||
const showPassword = ref(false);
|
||||
const acceptOffer = ref(false);
|
||||
const acceptPdn = ref(false);
|
||||
const code = ref('');
|
||||
const stage = ref<'form' | 'code'>('form');
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const resendCooldown = ref(0);
|
||||
let resendTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const phoneModel = computed({
|
||||
get: () => formatPhone(phoneRaw.value),
|
||||
set: (v: string) => {
|
||||
phoneRaw.value = phoneDigits(v);
|
||||
},
|
||||
});
|
||||
const phoneValid = computed(() => /^7\d{10}$/.test(phoneRaw.value));
|
||||
|
||||
// Простая оценка силы пароля 0..4 для индикатора. На backend будет zxcvbn.
|
||||
const passwordStrength = computed(() => {
|
||||
const v = password.value;
|
||||
@@ -35,40 +46,77 @@ const passwordStrength = computed(() => {
|
||||
if (/[^A-Za-zА-Яа-я0-9]/.test(v)) score++;
|
||||
return score;
|
||||
});
|
||||
const strengthLabel = computed(() => ['—', 'Слабый', 'Средний', 'Хороший', 'Надёжный'][passwordStrength.value]);
|
||||
const strengthColor = computed(() => ['', 'error', 'warning', 'info', 'success'][passwordStrength.value]);
|
||||
|
||||
const strengthLabel = computed(() => {
|
||||
const map = ['—', 'Слабый', 'Средний', 'Хороший', 'Надёжный'];
|
||||
return map[passwordStrength.value];
|
||||
});
|
||||
|
||||
const strengthColor = computed(() => {
|
||||
const map = ['', 'error', 'warning', 'info', 'success'];
|
||||
return map[passwordStrength.value];
|
||||
});
|
||||
|
||||
const canSubmit = computed(
|
||||
() => email.value.length > 0 && password.value.length >= 8 && acceptOffer.value && acceptPdn.value,
|
||||
const canSubmitForm = computed(
|
||||
() =>
|
||||
email.value.length > 0 &&
|
||||
phoneValid.value &&
|
||||
password.value.length >= 8 &&
|
||||
acceptOffer.value &&
|
||||
acceptPdn.value,
|
||||
);
|
||||
const canSubmitCode = computed(() => /^\d{6}$/.test(code.value));
|
||||
|
||||
async function handleSubmit() {
|
||||
function startCooldown() {
|
||||
resendCooldown.value = 60;
|
||||
if (resendTimer) clearInterval(resendTimer);
|
||||
resendTimer = setInterval(() => {
|
||||
resendCooldown.value -= 1;
|
||||
if (resendCooldown.value <= 0 && resendTimer) {
|
||||
clearInterval(resendTimer);
|
||||
resendTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
onUnmounted(() => {
|
||||
if (resendTimer) clearInterval(resendTimer);
|
||||
});
|
||||
|
||||
async function handleStart() {
|
||||
errors.value = {};
|
||||
try {
|
||||
const response = await auth.register({
|
||||
await auth.registerStart({
|
||||
email: email.value,
|
||||
phone: phoneRaw.value,
|
||||
password: password.value,
|
||||
accept_offer: acceptOffer.value,
|
||||
accept_pdn: acceptPdn.value,
|
||||
});
|
||||
await router.push(response.requires_2fa ? '/2fa' : '/dashboard');
|
||||
stage.value = 'code';
|
||||
startCooldown();
|
||||
} catch (error: unknown) {
|
||||
const validationErrors = extractValidationErrors(error);
|
||||
if (validationErrors) {
|
||||
errors.value = validationErrors;
|
||||
} else {
|
||||
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
|
||||
}
|
||||
errors.value = extractValidationErrors(error) ?? { email: ['Произошла ошибка. Попробуйте позже.'] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
errors.value = {};
|
||||
try {
|
||||
await auth.registerVerify(code.value);
|
||||
await router.push('/dashboard');
|
||||
} catch (error: unknown) {
|
||||
errors.value = extractValidationErrors(error) ?? { code: ['Произошла ошибка. Попробуйте позже.'] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
if (resendCooldown.value > 0) return;
|
||||
errors.value = {};
|
||||
try {
|
||||
await auth.registerResend();
|
||||
startCooldown();
|
||||
} catch (error: unknown) {
|
||||
errors.value = extractValidationErrors(error) ?? { code: ['Не удалось отправить код. Попробуйте позже.'] };
|
||||
}
|
||||
}
|
||||
|
||||
function backToForm() {
|
||||
stage.value = 'form';
|
||||
code.value = '';
|
||||
errors.value = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,7 +129,8 @@ async function handleSubmit() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<v-form class="register-form" @submit.prevent="handleSubmit">
|
||||
<!-- Шаг 1: форма -->
|
||||
<v-form v-if="stage === 'form'" class="register-form" @submit.prevent="handleStart">
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
label="Рабочий email"
|
||||
@@ -94,6 +143,18 @@ async function handleSubmit() {
|
||||
:error-messages="errors.email"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="phoneModel"
|
||||
label="Телефон"
|
||||
type="tel"
|
||||
autocomplete="tel"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
:error-messages="errors.phone"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
label="Пароль"
|
||||
@@ -156,12 +217,59 @@ async function handleSubmit() {
|
||||
block
|
||||
size="large"
|
||||
variant="flat"
|
||||
:disabled="!canSubmit"
|
||||
:disabled="!canSubmitForm"
|
||||
:loading="auth.loading"
|
||||
>
|
||||
Создать аккаунт
|
||||
Получить код
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<!-- Шаг 2: код -->
|
||||
<v-form v-else class="register-form" @submit.prevent="handleVerify">
|
||||
<p class="text-body-2 text-medium-emphasis mb-2">
|
||||
Мы отправили 6-значный код на <strong>{{ email }}</strong
|
||||
>. Введите его ниже.
|
||||
</p>
|
||||
|
||||
<v-text-field
|
||||
v-model="code"
|
||||
label="Код из письма"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="______"
|
||||
maxlength="6"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
:error-messages="errors.code"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
block
|
||||
size="large"
|
||||
variant="flat"
|
||||
:disabled="!canSubmitCode"
|
||||
:loading="auth.loading"
|
||||
>
|
||||
Подтвердить и создать аккаунт
|
||||
</v-btn>
|
||||
|
||||
<div class="code-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="link-btn text-primary"
|
||||
:disabled="resendCooldown > 0"
|
||||
@click="handleResend"
|
||||
>
|
||||
{{ resendCooldown > 0 ? `Отправить код повторно (${resendCooldown})` : 'Отправить код повторно' }}
|
||||
</button>
|
||||
<button type="button" class="link-btn text-medium-emphasis" @click="backToForm">
|
||||
Изменить данные
|
||||
</button>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -196,6 +304,31 @@ async function handleSubmit() {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.code-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.link-btn:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.password-toggle:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 1px;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Код подтверждения регистрации</title>
|
||||
</head>
|
||||
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
|
||||
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Подтверждение регистрации</h1>
|
||||
|
||||
<p>Ваш код подтверждения регистрации:</p>
|
||||
|
||||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 6px; color: #0F6E56; margin: 16px 0;">{{ $code }}</p>
|
||||
|
||||
<p>Код действует 15 минут. Введите его на странице регистрации, чтобы завершить создание аккаунта.</p>
|
||||
|
||||
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
|
||||
Если вы не регистрировались в Лидерре — просто проигнорируйте это письмо.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
+8
-16
@@ -19,7 +19,9 @@ use Illuminate\Support\Facades\Route;
|
||||
// добавляется только к web-группе. См. laravel.com/docs/sanctum#spa-authentication.
|
||||
Route::prefix('/api/auth')->group(function () {
|
||||
Route::post('/login', 'App\Http\Controllers\Api\AuthController@login');
|
||||
Route::post('/register', 'App\Http\Controllers\Api\AuthController@register');
|
||||
Route::post('/register/start', 'App\Http\Controllers\Api\AuthController@registerStart');
|
||||
Route::post('/register/verify', 'App\Http\Controllers\Api\AuthController@registerVerify');
|
||||
Route::post('/register/resend', 'App\Http\Controllers\Api\AuthController@registerResend');
|
||||
// /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только
|
||||
// pending_user_id в session. Verify завершает login после проверки TOTP.
|
||||
//
|
||||
@@ -194,12 +196,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
|
||||
});
|
||||
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). Go-live: auth:sanctum
|
||||
// + tenant; tenant_id из auth()->user()->tenant_id (SetTenantContext), НЕ из параметра
|
||||
// запроса — закрывает кросс-tenant утечку KPI (как DealController J1).
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
});
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
|
||||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||||
@@ -231,15 +230,8 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
});
|
||||
|
||||
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
|
||||
// Go-live: auth:sanctum. /api/managers — tenant-scoped (tenant_id из authed-user, НЕ из
|
||||
// параметра — закрывает кросс-tenant утечку списка пользователей); /api/lead-statuses —
|
||||
// глобальная таблица (без tenant_id), нужен только auth:sanctum.
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||||
});
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
|
||||
});
|
||||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||||
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
|
||||
|
||||
// Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS.
|
||||
// Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия).
|
||||
|
||||
@@ -119,57 +119,6 @@ test('POST /api/auth/login обновляет last_login_at у user', function (
|
||||
expect($user->fresh()->last_login_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('POST /api/auth/register создаёт user + возвращает 201', function () {
|
||||
$response = $this->postJson('/api/auth/register', [
|
||||
'email' => 'new-signup@example.ru',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('user.email', 'new-signup@example.ru');
|
||||
$response->assertJsonPath('requires_2fa', false);
|
||||
|
||||
$user = User::where('email', 'new-signup@example.ru')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect(Hash::check('fresh-pass-123', $user->password_hash))->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST /api/auth/register отвергает существующий email (unique)', function () {
|
||||
User::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'email' => 'duplicate@example.ru',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/register', [
|
||||
'email' => 'duplicate@example.ru',
|
||||
'password' => 'any-password-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['email']);
|
||||
});
|
||||
|
||||
test('POST /api/auth/register требует accept_offer=true И accept_pdn=true (ТЗ §1.5/§4.1)', function () {
|
||||
$base = [
|
||||
'email' => 'no-consent@example.ru',
|
||||
'password' => 'fresh-pass-123',
|
||||
];
|
||||
|
||||
// Без оферты.
|
||||
$this->postJson('/api/auth/register', array_merge($base, ['accept_pdn' => true]))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['accept_offer']);
|
||||
|
||||
// Без ПДн.
|
||||
$this->postJson('/api/auth/register', array_merge($base, ['accept_offer' => true]))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['accept_pdn']);
|
||||
});
|
||||
|
||||
test('GET /api/auth/me возвращает 401 без авторизации', function () {
|
||||
$this->getJson('/api/auth/me')->assertStatus(401);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\RegisterEmailVerificationCode;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
test('RegisterEmailVerificationCode содержит код и тему', function () {
|
||||
$mailable = new RegisterEmailVerificationCode('123456');
|
||||
|
||||
$mailable->assertHasSubject('Код подтверждения регистрации — Лидерра');
|
||||
$mailable->assertSeeInHtml('123456');
|
||||
});
|
||||
|
||||
test('register/start принимает валидную форму, шлёт код, аккаунт ещё не создан', function () {
|
||||
$response = $this->postJson('/api/auth/register/start', [
|
||||
'email' => 'newcomer@example.ru',
|
||||
'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('email', 'newcomer@example.ru');
|
||||
|
||||
Mail::assertSent(RegisterEmailVerificationCode::class);
|
||||
expect(User::where('email', 'newcomer@example.ru')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('register/start отвергает существующий email', function () {
|
||||
User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'dup@example.ru']);
|
||||
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'dup@example.ru',
|
||||
'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['email']);
|
||||
});
|
||||
|
||||
test('register/start требует корректный телефон', function () {
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'badphone@example.ru',
|
||||
'phone' => '12345',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['phone']);
|
||||
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'nophone@example.ru',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['phone']);
|
||||
});
|
||||
|
||||
test('register/start требует пароль ≥8 и оба согласия', function () {
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'weak@example.ru', 'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'short', 'accept_offer' => true, 'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['password']);
|
||||
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'noconsent@example.ru', 'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123', 'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['accept_offer']);
|
||||
});
|
||||
|
||||
test('register/start ограничивает число отправок кода (5/час по email|ip)', function () {
|
||||
$payload = [
|
||||
'email' => 'throttle@example.ru',
|
||||
'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
];
|
||||
|
||||
// 5 отправок разрешены (аккаунт не создаётся до verify, email остаётся свободным).
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->postJson('/api/auth/register/start', $payload)->assertOk();
|
||||
}
|
||||
|
||||
// 6-я — превышение лимита.
|
||||
$this->postJson('/api/auth/register/start', $payload)->assertStatus(429);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 4: register/verify
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Делает register/start и возвращает 6-значный код из отправленного письма.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
$startAndGetCode = function (array $overrides = []): string {
|
||||
/** @var TestCase $this */
|
||||
$payload = array_merge([
|
||||
'email' => 'verify-flow@example.ru',
|
||||
'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
], $overrides);
|
||||
|
||||
test()->postJson('/api/auth/register/start', $payload)->assertOk();
|
||||
|
||||
return Mail::sent(RegisterEmailVerificationCode::class)->first()->code;
|
||||
};
|
||||
|
||||
test('register/verify создаёт аккаунт с подтверждённой почтой и нормализованным телефоном', function () use ($startAndGetCode) {
|
||||
$code = $startAndGetCode();
|
||||
|
||||
$response = $this->postJson('/api/auth/register/verify', ['code' => $code]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('user.email', 'verify-flow@example.ru');
|
||||
$response->assertJsonPath('requires_2fa', false);
|
||||
|
||||
$user = User::where('email', 'verify-flow@example.ru')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect($user->phone)->toBe('79123456789');
|
||||
expect($user->email_verified_at)->not->toBeNull();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('register/verify отклоняет неверный код и считает попытки', function () use ($startAndGetCode) {
|
||||
$startAndGetCode();
|
||||
|
||||
$this->postJson('/api/auth/register/verify', ['code' => '000000'])
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
|
||||
expect(User::where('email', 'verify-flow@example.ru')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('register/verify сбрасывает pending после 5 неверных попыток', function () use ($startAndGetCode) {
|
||||
$startAndGetCode();
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->postJson('/api/auth/register/verify', ['code' => '000000'])->assertStatus(422);
|
||||
}
|
||||
|
||||
// 6-я попытка — pending уже сброшен (нет сессии регистрации).
|
||||
$this->postJson('/api/auth/register/verify', ['code' => '000000'])
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
});
|
||||
|
||||
test('register/verify без начатой регистрации возвращает 422', function () {
|
||||
$this->postJson('/api/auth/register/verify', ['code' => '123456'])
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
});
|
||||
|
||||
test('register/verify отклоняет истёкший код', function () use ($startAndGetCode) {
|
||||
$code = $startAndGetCode();
|
||||
|
||||
$this->travel(16)->minutes();
|
||||
|
||||
$this->postJson('/api/auth/register/verify', ['code' => $code])
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
expect(User::where('email', 'verify-flow@example.ru')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 5: register/resend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('register/resend в течение cooldown возвращает 429', function () use ($startAndGetCode) {
|
||||
$startAndGetCode();
|
||||
|
||||
$this->postJson('/api/auth/register/resend')->assertStatus(429);
|
||||
});
|
||||
|
||||
test('register/resend после cooldown шлёт новый код', function () use ($startAndGetCode) {
|
||||
$startAndGetCode();
|
||||
Mail::fake(); // сбрасываем счётчик отправок
|
||||
|
||||
$this->travel(61)->seconds();
|
||||
|
||||
$this->postJson('/api/auth/register/resend')->assertOk();
|
||||
Mail::assertSent(RegisterEmailVerificationCode::class, 1);
|
||||
});
|
||||
|
||||
test('register/resend без начатой регистрации возвращает 422', function () {
|
||||
$this->postJson('/api/auth/register/resend')
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('backfill: добавляет root-link для проекта-субдомена с уже-существующими линками', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
|
||||
]);
|
||||
|
||||
$subdomainSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'krasnoyarsk.carmoney.ru',
|
||||
'supplier_external_id' => 'ext-sub',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
$rootSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext-root',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $subdomainSp->id,
|
||||
'platform' => 'B2',
|
||||
'subject_code' => null,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('supplier:backfill-root-links');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
expect(
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
it('backfill: idempotent — повторный прогон ничего не добавляет', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.carmoney.ru',
|
||||
]);
|
||||
$subSp = SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'client.carmoney.ru',
|
||||
'supplier_external_id' => 'ext1', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext2', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
|
||||
'platform' => 'B2', 'subject_code' => null,
|
||||
]);
|
||||
|
||||
Artisan::call('supplier:backfill-root-links');
|
||||
$afterFirst = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
Artisan::call('supplier:backfill-root-links');
|
||||
$afterSecond = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
|
||||
expect($afterFirst)->toBe(2);
|
||||
expect($afterSecond)->toBe(2);
|
||||
});
|
||||
|
||||
it('backfill --dry-run: ничего не пишет в БД', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'next.vashinvestor.ru',
|
||||
]);
|
||||
$subSp = SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'next.vashinvestor.ru',
|
||||
'supplier_external_id' => 'extn1', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'vashinvestor.ru',
|
||||
'supplier_external_id' => 'extn2', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
|
||||
'platform' => 'B2', 'subject_code' => null,
|
||||
]);
|
||||
|
||||
Artisan::call('supplier:backfill-root-links', ['--dry-run' => true]);
|
||||
|
||||
$count = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($count)->toBe(1);
|
||||
});
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
@@ -37,14 +36,12 @@ function makeDashboardDeal(
|
||||
]);
|
||||
}
|
||||
|
||||
/** Авторизоваться как пользователь данного тенанта (auth:sanctum + tenant). */
|
||||
function actingForTenant(Tenant $tenant): void
|
||||
{
|
||||
test()->actingAs(User::factory()->for($tenant)->create());
|
||||
}
|
||||
it('422 без tenant_id', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(422);
|
||||
});
|
||||
|
||||
it('401 без авторизации', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(401);
|
||||
it('404 для несуществующего тенанта', function () {
|
||||
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
@@ -53,8 +50,7 @@ it('возвращает структуру summary с range по умолчан
|
||||
'balance_rub' => '14250.00',
|
||||
'balance_leads' => 285,
|
||||
]);
|
||||
actingForTenant($tenant);
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('range', '7d')
|
||||
->assertJsonPath('balance.amount_rub', '14250.00')
|
||||
@@ -71,7 +67,6 @@ it('возвращает структуру summary с range по умолчан
|
||||
|
||||
it('leads_received считает только сделки окна, без deleted и is_test', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
@@ -81,32 +76,30 @@ it('leads_received считает только сделки окна, без del
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
|
||||
$this->getJson('/api/dashboard/summary?range=7d')
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
|
||||
->assertOk()
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса won в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
});
|
||||
|
||||
it('active_projects считает is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
actingForTenant($tenant);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
@@ -114,12 +107,11 @@ it('active_projects считает is_active=true + limit из limits', function
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.won', 1);
|
||||
@@ -127,8 +119,7 @@ it('funnel группирует живые сделки по статусу', fu
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(7, 'activity.points')
|
||||
->assertJsonCount(7, 'activity.labels');
|
||||
@@ -138,12 +129,11 @@ it('runway_days использует фикс. 7д-окно независимо
|
||||
// balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70.
|
||||
// Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно).
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 70]);
|
||||
actingForTenant($tenant);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
for ($i = 0; $i <= 6; $i++) {
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays($i));
|
||||
}
|
||||
$this->getJson('/api/dashboard/summary?range=today')
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=today")
|
||||
->assertOk()
|
||||
->assertJsonPath('balance.runway_days', 70);
|
||||
});
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Go-live security: lookup/дашборд эндпоинты до этого были открыты (без
|
||||
* auth-middleware, tenant_id параметром) — любой неавторизованный мог получить
|
||||
* KPI/список пользователей произвольного тенанта по ?tenant_id={чужой}.
|
||||
*
|
||||
* Закрытие: auth:sanctum + tenant, tenant_id из authed-user (как DealController J1).
|
||||
*/
|
||||
|
||||
// --- 401 без авторизации ---
|
||||
|
||||
test('GET /api/dashboard/summary без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/managers без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/managers')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/lead-statuses')->assertStatus(401);
|
||||
});
|
||||
|
||||
// --- cross-tenant: tenant_id из user, параметр чужого тенанта игнорируется ---
|
||||
|
||||
test('dashboard/summary берёт tenant из authed-user, игнорирует ?tenant_id чужого', function () {
|
||||
$mine = Tenant::factory()->create(['balance_rub' => '111.00', 'balance_leads' => 11]);
|
||||
$other = Tenant::factory()->create(['balance_rub' => '999.00', 'balance_leads' => 99]);
|
||||
$this->actingAs(User::factory()->for($mine)->create());
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$other->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('balance.amount_rub', '111.00');
|
||||
});
|
||||
|
||||
test('managers берёт tenant из authed-user, не отдаёт пользователей чужого тенанта', function () {
|
||||
$mine = Tenant::factory()->create();
|
||||
$other = Tenant::factory()->create();
|
||||
$me = User::factory()->for($mine)->create(['first_name' => 'Свой', 'last_name' => 'Менеджер', 'is_active' => true]);
|
||||
User::factory()->for($other)->create(['first_name' => 'Чужой', 'last_name' => 'Менеджер', 'is_active' => true]);
|
||||
$this->actingAs($me);
|
||||
|
||||
$names = $this->getJson("/api/managers?tenant_id={$other->id}")
|
||||
->assertOk()
|
||||
->json('managers.*.name');
|
||||
|
||||
expect($names)->toContain('Свой М.');
|
||||
expect($names)->not->toContain('Чужой М.');
|
||||
});
|
||||
@@ -7,13 +7,8 @@ use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// SaaS-admin impersonation запрашивает impersonation_tokens/tenants через
|
||||
// BYPASSRLS-подключение pgsql_supplier (RLS-фикс). Под DatabaseTransactions
|
||||
// данные default-подключения не видны pgsql_supplier до commit'а → SharesSupplierPdo
|
||||
// шарит PDO между подключениями (как в tests/Feature/Supplier/*).
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
@@ -72,20 +67,6 @@ test('GET /api/admin/impersonation/active возвращает активные
|
||||
expect($sessions[0]['reason'])->toContain('active session');
|
||||
});
|
||||
|
||||
test('active() читает impersonation_tokens через BYPASSRLS-подключение pgsql_supplier (regression RLS-фикс)', function () {
|
||||
$connections = [];
|
||||
DB::listen(function ($query) use (&$connections) {
|
||||
if (str_contains($query->sql, 'impersonation_tokens')) {
|
||||
$connections[] = $query->connectionName;
|
||||
}
|
||||
});
|
||||
|
||||
$this->getJson('/api/admin/impersonation/active')->assertStatus(200);
|
||||
|
||||
expect($connections)->not->toBeEmpty();
|
||||
expect(array_values(array_unique($connections)))->toBe(['pgsql_supplier']);
|
||||
});
|
||||
|
||||
test('GET /api/admin/impersonation/recent возвращает завершённые сессии с длительностью', function () {
|
||||
ImpersonationToken::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
|
||||
@@ -48,21 +48,6 @@ function runRouteJob(int $supplierLeadId): void
|
||||
|
||||
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
|
||||
|
||||
it('is terminal (does not throw / re-queue) when the supplier lead does not exist', function (): void {
|
||||
// Регрессия retry-шторма 21-22.05.2026: RouteSupplierLeadJob для удалённого лида №1
|
||||
// бросал ModelNotFoundException -> queue->failed() писал в failed_webhook_jobs ->
|
||||
// RetryFailedSupplierJobsCommand бесконечно перезапускал (25k+ записей).
|
||||
// «Лид не найден» — терминальная (не транзиентная) ошибка: повтор бессмыслен.
|
||||
$missingId = 999999;
|
||||
expect(SupplierLead::find($missingId))->toBeNull();
|
||||
|
||||
// Не должно бросать исключение (иначе сработает failed() -> retry-цикл).
|
||||
runRouteJob($missingId);
|
||||
|
||||
// Никаких побочных эффектов.
|
||||
expect(Deal::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -11,23 +9,11 @@ use Illuminate\Support\Facades\DB;
|
||||
* Тесты GET /api/lead-statuses — глобальный lookup статусов воронки.
|
||||
*
|
||||
* Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных
|
||||
* статусов воронки: new/viewed/in_progress/won/lost). Go-live: эндпоинт за
|
||||
* auth:sanctum (глобальная таблица — tenant-middleware не нужен).
|
||||
* статусов воронки: new/viewed/in_progress/won/lost).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/** Авторизоваться любым пользователем (lead-statuses требует только auth:sanctum). */
|
||||
function authLeadStatuses(): void
|
||||
{
|
||||
test()->actingAs(User::factory()->for(Tenant::factory())->create());
|
||||
}
|
||||
|
||||
test('GET /api/lead-statuses без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/lead-statuses')->assertStatus(401);
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает 200 и не пустой список', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$r->assertStatus(200);
|
||||
@@ -36,7 +22,6 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$slugs = collect($r->json('lead_statuses'))->pluck('slug')->all();
|
||||
@@ -47,7 +32,6 @@ test('GET /api/lead-statuses возвращает все 5 системных с
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает поля slug, name_ru, color_hex, sort_order, is_system', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$first = $r->json('lead_statuses.0');
|
||||
@@ -58,7 +42,6 @@ test('GET /api/lead-statuses возвращает поля slug, name_ru, color_
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses сортирует по sort_order', function () {
|
||||
authLeadStatuses();
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$sortOrders = collect($r->json('lead_statuses'))->pluck('sort_order')->all();
|
||||
@@ -68,7 +51,6 @@ test('GET /api/lead-statuses сортирует по sort_order', function () {
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses включает кастомный slug, добавленный после seed', function () {
|
||||
authLeadStatuses();
|
||||
DB::table('lead_statuses')->insert([
|
||||
'slug' => 'custom_test_'.bin2hex(random_bytes(3)),
|
||||
'name_ru' => 'Кастомный тест',
|
||||
|
||||
@@ -15,8 +15,7 @@ beforeEach(function () {
|
||||
|
||||
test('GET /api/managers возвращает active users тенанта', function () {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
// actingAs одного из активных пользователей тенанта — он сам входит в список.
|
||||
$ivan = User::factory()->for($this->tenant)->create([
|
||||
User::factory()->for($this->tenant)->create([
|
||||
'first_name' => 'Иван', 'last_name' => 'Петров', 'is_active' => true,
|
||||
]);
|
||||
User::factory()->for($this->tenant)->create([
|
||||
@@ -26,8 +25,7 @@ test('GET /api/managers возвращает active users тенанта', funct
|
||||
'first_name' => 'Удалённый', 'is_active' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($ivan);
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id);
|
||||
$r->assertStatus(200);
|
||||
$managers = $r->json('managers');
|
||||
expect($managers)->toHaveCount(2);
|
||||
@@ -37,23 +35,28 @@ test('GET /api/managers возвращает active users тенанта', funct
|
||||
|
||||
test('GET /api/managers возвращает initials с fallback на email', function () {
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$admin = User::factory()->for($this->tenant)->create([
|
||||
User::factory()->for($this->tenant)->create([
|
||||
'email' => 'admin@example.ru',
|
||||
'first_name' => null,
|
||||
'last_name' => null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin);
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r = $this->getJson('/api/managers?tenant_id='.$this->tenant->id);
|
||||
$r->assertStatus(200);
|
||||
$manager = $r->json('managers.0');
|
||||
expect($manager['name'])->toBe('admin@example.ru');
|
||||
expect($manager['initials'])->toBe('AD');
|
||||
});
|
||||
|
||||
test('GET /api/managers без авторизации возвращает 401', function () {
|
||||
$this->getJson('/api/managers')->assertStatus(401);
|
||||
test('GET /api/managers 422 без tenant_id', function () {
|
||||
$r = $this->getJson('/api/managers');
|
||||
$r->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/managers 404 unknown tenant', function () {
|
||||
$r = $this->getJson('/api/managers?tenant_id=999999');
|
||||
$r->assertStatus(404);
|
||||
});
|
||||
|
||||
test('POST /api/deals 422 если manager_id не принадлежит tenant\'у', function () {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Pest\Laravel\get;
|
||||
|
||||
// Гейт SaaS-admin зоны (middleware EnsureSaasAdmin). Вне local/testing зона
|
||||
// закрыта (503), кроме случая включённого временного флага тест-деплоя.
|
||||
|
||||
it('blocks saas-admin area outside local/testing without bypass flag', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config(['app.saas_admin_test_bypass' => false]);
|
||||
|
||||
get('/api/admin/tenants')->assertStatus(503);
|
||||
});
|
||||
|
||||
it('allows saas-admin area when test bypass flag is enabled', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config(['app.saas_admin_test_bypass' => true]);
|
||||
|
||||
expect(get('/api/admin/tenants')->status())->not->toBe(503);
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
|
||||
* по всем трём путям — ручной API, поставщик (RouteSupplierLeadJob),
|
||||
* вебхук (ProcessWebhookJob).
|
||||
*/
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Path A: manual deal creation via DealController::store()
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('writes pd_processing_log created (manual) when deal created via API', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$before = DB::table('pd_processing_log')->where('purpose', 'lead_create_manual')->count();
|
||||
|
||||
$r = $this->postJson('/api/deals', [
|
||||
'project_name' => 'Тест ПД',
|
||||
'phone' => '+7 (999) 111-22-33',
|
||||
]);
|
||||
$r->assertStatus(201);
|
||||
|
||||
$dealId = $r->json('deal.id');
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_manual')
|
||||
->where('subject_type', 'lead')
|
||||
->where('subject_id', $dealId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('actor_tenant_user_id', $user->id)
|
||||
->whereNull('actor_admin_user_id')
|
||||
->count();
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Path B: supplier integration via RouteSupplierLeadJob
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('writes pd_processing_log created (supplier) when deal created via RouteSupplierLeadJob', function () {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'pd-test.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'pd-test.ru',
|
||||
'is_active' => true,
|
||||
'delivered_today' => 0,
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$vid = 77741;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'vid' => $vid,
|
||||
'phone' => '79992223344',
|
||||
'raw_payload' => [
|
||||
'vid' => $vid,
|
||||
'project' => 'B1_pd-test.ru',
|
||||
'phone' => '79992223344',
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
]);
|
||||
|
||||
(new RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_supplier')
|
||||
->where('subject_type', 'lead')
|
||||
->where('subject_id', $deal->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('actor_tenant_user_id')
|
||||
->whereNull('actor_admin_user_id')
|
||||
->count();
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Path C: webhook via ProcessWebhookJob
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('writes pd_processing_log created (webhook) when deal created via ProcessWebhookJob', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
$vid = 55566;
|
||||
(new ProcessWebhookJob($tenant->id, [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_PdWebhookTest',
|
||||
'tag' => 'PdWebhookTest',
|
||||
'phone' => '79001112233',
|
||||
'phones' => ['79001112233'],
|
||||
'time' => time(),
|
||||
]))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_webhook')
|
||||
->where('subject_type', 'lead')
|
||||
->where('subject_id', $deal->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('actor_tenant_user_id')
|
||||
->whereNull('actor_admin_user_id')
|
||||
->count();
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create();
|
||||
});
|
||||
|
||||
it('pd exported on deals CSV export', function () {
|
||||
$r = $this->post('/api/deals/export', ['format' => 'csv']);
|
||||
$r->assertStatus(200);
|
||||
$pd = DB::table('pd_processing_log')->where('action', 'exported')->latest('id')->first();
|
||||
expect($pd)->not->toBeNull()
|
||||
->and($pd->subject_type)->toBe('lead')
|
||||
->and($pd->subject_id)->toBeNull()
|
||||
->and($pd->purpose)->toBe('deals_export_csv')
|
||||
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id);
|
||||
});
|
||||
|
||||
it('pd exported with xlsx purpose', function () {
|
||||
$r = $this->post('/api/deals/export', ['format' => 'xlsx']);
|
||||
$r->assertStatus(200);
|
||||
$pd = DB::table('pd_processing_log')->where('action', 'exported')->latest('id')->first();
|
||||
expect($pd)->not->toBeNull()
|
||||
->and($pd->subject_type)->toBe('lead')
|
||||
->and($pd->subject_id)->toBeNull()
|
||||
->and($pd->purpose)->toBe('deals_export_xlsx')
|
||||
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id);
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
|
||||
* через исторический импорт (HistoricalImportService).
|
||||
*/
|
||||
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Import\CsvLeadsParser;
|
||||
use App\Services\Import\HistoricalImportService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('writes pd_processing_log created on historical import for each new deal', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/x.csv',
|
||||
'dry_run' => false,
|
||||
]);
|
||||
|
||||
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
$rows = array_merge(
|
||||
(new CsvLeadsParser)->parse($header."\n".'9901,Окна,окна,79161000001,2023/07/10 10:00:00,,,Новые,')->rows,
|
||||
(new CsvLeadsParser)->parse($header."\n".'9902,Окна,окна,79161000002,2023/07/10 10:00:00,,,Новые,')->rows,
|
||||
);
|
||||
|
||||
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log, $rows);
|
||||
|
||||
$pd = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_import_'.$log->id)
|
||||
->get();
|
||||
|
||||
expect($pd)->toHaveCount(2);
|
||||
|
||||
foreach ($pd as $r) {
|
||||
expect($r->subject_type)->toBe('lead')
|
||||
->and((int) $r->actor_tenant_user_id)->toBe($user->id)
|
||||
->and($r->actor_admin_user_id)->toBeNull()
|
||||
->and($r->subject_id)->not->toBeNull()
|
||||
->and((int) $r->tenant_id)->toBe($tenant->id);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT write pd_processing_log on dry_run import', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$log = ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/x.csv',
|
||||
'dry_run' => true,
|
||||
]);
|
||||
|
||||
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
$rows = (new CsvLeadsParser)->parse($header."\n".'9903,Окна,окна,79161000003,2023/07/10 10:00:00,,,Новые,')->rows;
|
||||
|
||||
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log, $rows);
|
||||
|
||||
$count = DB::table('pd_processing_log')
|
||||
->where('purpose', 'lead_create_import_'.$log->id)
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT write pd_processing_log on import UPDATE (idempotent re-import)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
|
||||
|
||||
// First import — creates the deal
|
||||
$log1 = ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads.csv',
|
||||
'file_path' => 'imports/x.csv',
|
||||
'dry_run' => false,
|
||||
]);
|
||||
$rows1 = (new CsvLeadsParser)->parse($header."\n".'9904,Окна,окна,79161000004,2023/07/10 10:00:00,,,Новые,')->rows;
|
||||
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log1, $rows1);
|
||||
|
||||
// Second import — updates the same deal
|
||||
$log2 = ImportLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'filename' => 'leads2.csv',
|
||||
'file_path' => 'imports/x2.csv',
|
||||
'dry_run' => false,
|
||||
]);
|
||||
$rows2 = (new CsvLeadsParser)->parse($header."\n".'9904,Окна,окна,79161000004,2023/07/10 10:00:00,,,Оплачено,')->rows;
|
||||
app(HistoricalImportService::class)->import($tenant->id, $user->id, $log2, $rows2);
|
||||
|
||||
// Only the first import wrote a pd log entry
|
||||
$countLog1 = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_import_'.$log1->id)
|
||||
->count();
|
||||
$countLog2 = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_import_'.$log2->id)
|
||||
->count();
|
||||
|
||||
expect($countLog1)->toBe(1)
|
||||
->and($countLog2)->toBe(0);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
$this->deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
|
||||
});
|
||||
|
||||
it('writes pd_processing_log viewed when deal card opened', function () {
|
||||
$this->getJson("/api/deals/{$this->deal->id}")->assertOk();
|
||||
|
||||
$row = DB::table('pd_processing_log')->where('action', 'viewed')->latest('id')->first();
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->subject_type)->toBe('lead')
|
||||
->and((int) $row->subject_id)->toBe($this->deal->id)
|
||||
->and((int) $row->actor_tenant_user_id)->toBe($this->user->id)
|
||||
->and($row->purpose)->toBe('lead_card_view');
|
||||
});
|
||||
|
||||
it('does not write pd_processing_log for 404 lookups', function () {
|
||||
$before = DB::table('pd_processing_log')->count();
|
||||
$this->getJson('/api/deals/999999')->assertNotFound();
|
||||
expect(DB::table('pd_processing_log')->count())->toBe($before);
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create(['contact_email' => 'tenant-admin@example.ru']);
|
||||
$this->adminId = DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'admin-saas-'.uniqid().'@liderra.ru',
|
||||
'full_name' => 'SaaS Admin',
|
||||
'password_hash' => '$2y$04$dummy-hash-for-test',
|
||||
'role' => 'support',
|
||||
'is_active' => true,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('init writes saas_admin_audit_log impersonation.init', function () {
|
||||
$reason = 'support investigation '.str_repeat('x', 30);
|
||||
$r = $this->postJson('/api/admin/impersonation/init', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'requested_by' => $this->adminId,
|
||||
'reason' => $reason,
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::table('saas_admin_audit_log')->where('action', 'impersonation.init')->latest('id')->first();
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->admin_user_id)->toBe($this->adminId)
|
||||
->and((int) $row->target_id)->toBe($this->tenant->id)
|
||||
->and($row->reason)->toBe($reason);
|
||||
});
|
||||
|
||||
it('verify writes saas_audit impersonation.verify + pd_processing_log viewed', function () {
|
||||
$token = ImpersonationToken::create([
|
||||
'tenant_id' => $this->tenant->id, 'requested_by' => $this->adminId,
|
||||
'code_hash' => Hash::make('123456'),
|
||||
'reason' => 'verify case '.str_repeat('y', 30),
|
||||
'sent_to_email' => 'a@b.ru', 'expires_at' => now()->addMinutes(15),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/admin/impersonation/verify', ['token_id' => $token->id, 'code' => '123456'])->assertOk();
|
||||
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.verify')->count())->toBe(1)
|
||||
->and(DB::table('pd_processing_log')
|
||||
->where('action', 'viewed')
|
||||
->where('purpose', 'impersonation_session_'.$token->id)
|
||||
->where('actor_admin_user_id', $this->adminId)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('end writes saas_admin_audit_log impersonation.end', function () {
|
||||
$token = ImpersonationToken::create([
|
||||
'tenant_id' => $this->tenant->id, 'requested_by' => $this->adminId,
|
||||
'code_hash' => Hash::make('123456'),
|
||||
'reason' => 'end case '.str_repeat('z', 30),
|
||||
'sent_to_email' => 'a@b.ru', 'expires_at' => now()->addMinutes(15),
|
||||
'used_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/admin/impersonation/end', ['token_id' => $token->id])->assertOk();
|
||||
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.end')->count())->toBe(1);
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 152-ФЗ integration: pd_processing_log captures the full deal lifecycle
|
||||
* for one tenant — create → view → export → delete.
|
||||
*
|
||||
* Uses the deterministic manual-API path (no supplier/webhook jobs) so the
|
||||
* test is robust and self-contained.
|
||||
*
|
||||
* Convention mirrors: DealCreateTest / DealExportPdLogTest /
|
||||
* DealViewAccessLogTest / ReportFileDeletePdLogTest
|
||||
*/
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$this->tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.(int) $this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'PD Flow Test']);
|
||||
});
|
||||
|
||||
it('records pd events through the whole deal lifecycle (create → view → export → delete)', function () {
|
||||
|
||||
// ── 1. CREATE (manual) → pd action='created', purpose='lead_create_manual' ──
|
||||
$created = $this->postJson('/api/deals', [
|
||||
'project_name' => $this->project->name,
|
||||
'phone' => '+7 (999) 123-45-67',
|
||||
]);
|
||||
$created->assertStatus(201);
|
||||
$dealId = (int) $created->json('deal.id');
|
||||
expect($dealId)->toBeGreaterThan(0);
|
||||
|
||||
// ── 2. VIEW → pd action='viewed', purpose='lead_card_view' ──
|
||||
$this->getJson("/api/deals/{$dealId}")->assertOk();
|
||||
|
||||
// ── 3. EXPORT → pd action='exported', purpose='deals_export_csv' ──
|
||||
// Mirror: DealExportPdLogTest — POST /api/deals/export with format=csv
|
||||
// We need at least one deal in the tenant for a non-empty export; the
|
||||
// deal we just created qualifies.
|
||||
$exported = $this->post('/api/deals/export', ['format' => 'csv']);
|
||||
$exported->assertStatus(200);
|
||||
|
||||
// ── 4. DELETE report file → pd action='deleted', purpose='report_file_{id}' ──
|
||||
// Mirror: ReportFileDeletePdLogTest — create a DONE ReportJob with file_path,
|
||||
// then DELETE /api/reports/jobs/{id}.
|
||||
$job = ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv', 'date_from' => '2026-01-01', 'date_to' => '2026-12-31'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => 'reports/'.(int) $this->tenant->id.'/pd_flow_test.csv',
|
||||
]);
|
||||
|
||||
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertOk();
|
||||
|
||||
// ── ASSERT — scoped to THIS tenant ────────────────────────────────────────
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('tenant_id', $this->tenant->id)
|
||||
->get();
|
||||
$byAction = $rows->groupBy('action');
|
||||
|
||||
// All four lifecycle actions must be present.
|
||||
expect($byAction->has('created'))->toBeTrue()
|
||||
->and($byAction->has('viewed'))->toBeTrue()
|
||||
->and($byAction->has('exported'))->toBeTrue()
|
||||
->and($byAction->has('deleted'))->toBeTrue();
|
||||
|
||||
// Correct purpose for each action.
|
||||
expect($rows->firstWhere('action', 'created')->purpose)->toBe('lead_create_manual');
|
||||
expect($rows->firstWhere('action', 'viewed')->purpose)->toBe('lead_card_view');
|
||||
expect($rows->contains(fn ($r) => $r->action === 'exported' && $r->purpose === 'deals_export_csv'))->toBeTrue();
|
||||
expect($rows->firstWhere('action', 'deleted')->purpose)->toBe('report_file_'.$job->id);
|
||||
|
||||
// 'created' and 'viewed' rows are tied to the deal we created.
|
||||
expect((int) $rows->firstWhere('action', 'created')->subject_id)->toBe($dealId);
|
||||
expect((int) $rows->firstWhere('action', 'viewed')->subject_id)->toBe($dealId);
|
||||
|
||||
// All rows carry the correct actor.
|
||||
foreach (['created', 'viewed', 'exported', 'deleted'] as $action) {
|
||||
$row = $rows->firstWhere('action', $action);
|
||||
expect((int) $row->actor_tenant_user_id)->toBe($this->user->id);
|
||||
expect($row->actor_admin_user_id)->toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.(int) $this->tenant->id);
|
||||
});
|
||||
|
||||
it('writes pd deleted when a report file is destroyed', function () {
|
||||
$job = ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => 'reports/'.(int) $this->tenant->id.'/test.csv',
|
||||
]);
|
||||
|
||||
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertOk();
|
||||
|
||||
$pd = DB::table('pd_processing_log')
|
||||
->where('action', 'deleted')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
expect($pd)->not->toBeNull()
|
||||
->and($pd->subject_type)->toBe('lead')
|
||||
->and($pd->purpose)->toBe('report_file_'.$job->id)
|
||||
->and((int) $pd->actor_tenant_user_id)->toBe($this->user->id)
|
||||
->and((int) $pd->tenant_id)->toBe((int) $this->tenant->id);
|
||||
});
|
||||
|
||||
it('writes pd deleted (system actor) when cron cleanup-expired runs', function () {
|
||||
Storage::disk('local')->put('reports/'.(int) $this->tenant->id.'/cron1.csv', 'data');
|
||||
Storage::disk('local')->put('reports/'.(int) $this->tenant->id.'/cron2.csv', 'data');
|
||||
|
||||
ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => 'reports/'.(int) $this->tenant->id.'/cron1.csv',
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
ReportJob::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'type' => 'deals_export',
|
||||
'parameters' => ['format' => 'csv'],
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => 'reports/'.(int) $this->tenant->id.'/cron2.csv',
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->artisan('reports:cleanup-expired')->assertExitCode(0);
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'deleted')
|
||||
->where('purpose', 'like', 'report_cleanup_expired_%')
|
||||
->where('tenant_id', $this->tenant->id)
|
||||
->get();
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
foreach ($rows as $r) {
|
||||
expect($r->actor_tenant_user_id)->toBeNull()
|
||||
->and($r->actor_admin_user_id)->toBeNull()
|
||||
->and($r->subject_type)->toBe('lead');
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,9 @@ use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
|
||||
@@ -151,3 +153,82 @@ it('idempotency: pre-existing supplier_project row is reused, channel not called
|
||||
expect($project->supplier_b1_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
// Spec 2026-05-22: автолинковка субдомен-проекта к корневому supplier_project.
|
||||
// Тесты идут по handleOnline-ветке (project_supplier_links заполняется только там).
|
||||
it('site subdomain project: also links to root-domain supplier_project if exists', function () {
|
||||
DB::connection('pgsql_supplier')->table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['key' => 'supplier_export_mode', 'value' => 'online'],
|
||||
);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
|
||||
]);
|
||||
|
||||
$rootSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext-root-b2',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
// handleOnline path использует SupplierPortalClient::saveProjectMultiFlag, не channel.
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('saveProjectMultiFlag')->andReturn([
|
||||
'B1' => 700101, 'B2' => 700102, 'B3' => 700103,
|
||||
]);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
|
||||
$ownLinks = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($ownLinks)->toBe(4);
|
||||
|
||||
$rootLink = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists();
|
||||
expect($rootLink)->toBeTrue();
|
||||
});
|
||||
|
||||
it('site root-level project: does NOT create additional links (no recursion to TLD)', function () {
|
||||
DB::connection('pgsql_supplier')->table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['key' => 'supplier_export_mode', 'value' => 'online'],
|
||||
);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'carmoney.ru',
|
||||
]);
|
||||
|
||||
SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'ru',
|
||||
'supplier_external_id' => 'ext-ru-b2',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('saveProjectMultiFlag')->andReturn([
|
||||
'B1' => 700201, 'B2' => 700202, 'B3' => 700203,
|
||||
]);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
|
||||
$linksCount = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($linksCount)->toBe(3);
|
||||
});
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'info@lkomega.ru']);
|
||||
|
||||
$client = Mockery::mock(SupplierPortalClient::class);
|
||||
$client->shouldReceive('listProjects')->andReturn([
|
||||
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
]);
|
||||
$this->app->instance(SupplierPortalClient::class, $client);
|
||||
});
|
||||
|
||||
test('dry-run prints plan and writes nothing', function (): void {
|
||||
Http::fake();
|
||||
|
||||
$this->artisan('supplier:import-projects', ['--tenant' => 'info@lkomega.ru'])
|
||||
->assertExitCode(0);
|
||||
|
||||
expect(Project::on('pgsql_supplier')->where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('--commit writes projects', function (): void {
|
||||
Http::fake();
|
||||
|
||||
$this->artisan('supplier:import-projects', ['--tenant' => 'info@lkomega.ru', '--commit' => true])
|
||||
->assertExitCode(0);
|
||||
|
||||
expect(Project::on('pgsql_supplier')
|
||||
->where('tenant_id', $this->tenant->id)
|
||||
->where('signal_identifier', '79991112233')->count())->toBe(1);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('unknown tenant email → non-zero exit, no write', function (): void {
|
||||
$this->artisan('supplier:import-projects', ['--tenant' => 'nobody@nowhere.ru', '--commit' => true])
|
||||
->assertExitCode(1);
|
||||
});
|
||||
@@ -99,9 +99,7 @@ it('saveProject maps signalType call → type:"calls" and B2 → srcbl=true (sin
|
||||
&& $request['srcrt'] === false
|
||||
&& $request['srcbl'] === true
|
||||
&& $request['srcmt'] === false
|
||||
// Лидерра-код 77 (Тюменская обл., конституционный порядок) переводится
|
||||
// в код поставщика 72 (ГИБДД). См. App\Support\SupplierRegions.
|
||||
&& $request['regions'] === [72]
|
||||
&& $request['regions'] === [77]
|
||||
&& $request['regions_reverse'] === true
|
||||
&& $request['status'] === false;
|
||||
});
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Import\SupplierProjectImporter;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
function importerWithRows(array $rows): SupplierProjectImporter
|
||||
{
|
||||
$client = Mockery::mock(SupplierPortalClient::class);
|
||||
$client->shouldReceive('listProjects')->andReturn($rows);
|
||||
|
||||
return new SupplierProjectImporter($client);
|
||||
}
|
||||
|
||||
test('buildPlan groups B1/B2/B3 call rows into one planned project, limit = sum', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(1);
|
||||
$p = $plan['planned'][0];
|
||||
expect($p['signal_type'])->toBe('call');
|
||||
expect($p['signal_identifier'])->toBe('79991112233');
|
||||
expect($p['daily_limit_target'])->toBe(18);
|
||||
expect($p['delivery_days_mask'])->toBe(31);
|
||||
expect($p['tag'])->toBe('Каранга');
|
||||
expect($p['regions'])->toBe([]);
|
||||
expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
|
||||
expect(collect($p['platforms'])->firstWhere('platform', 'B1')['external_id'])->toBe(4001);
|
||||
});
|
||||
|
||||
test('buildPlan skips inactive rows (status=false)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79995550000', 'tag' => 'X', 'lim' => '5', 'status' => false, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('buildPlan skips dop2 (unsupported source) and reports it', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '6001', 'src' => 'dop2', 'type' => 'calls', 'content' => '79996660000', 'tag' => 'X', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
expect(collect($plan['skipped'])->pluck('reason'))->toContain('unsupported_source');
|
||||
});
|
||||
|
||||
test('buildPlan reverse-maps regions and unions across platforms', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '7001', 'src' => 'rt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
['id' => '7002', 'src' => 'bl', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '77', 'workdays' => [], 'regions_reverse' => false],
|
||||
['id' => '7003', 'src' => 'mt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'][0]['regions'])->toBe([29, 82]);
|
||||
});
|
||||
|
||||
test('buildPlan treats any empty-regions platform as all-Russia', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '7101', 'src' => 'rt', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
['id' => '7102', 'src' => 'bl', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '', 'workdays' => [], 'regions_reverse' => false],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'][0]['regions'])->toBe([]);
|
||||
});
|
||||
|
||||
test('buildPlan skips group when any active row has regions_reverse=true', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '7201', 'src' => 'rt', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true],
|
||||
['id' => '7202', 'src' => 'bl', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude');
|
||||
});
|
||||
|
||||
test('buildPlan groups sms by sender: B2 (sender+keyword) and B3 (sender)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '8001', 'src' => 'bl', 'type' => 'sms', 'content' => '79001234567+KVARTIRA', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
['id' => '8002', 'src' => 'mt', 'type' => 'sms', 'content' => '79001234567', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(1);
|
||||
$p = $plan['planned'][0];
|
||||
expect($p['signal_type'])->toBe('sms');
|
||||
expect($p['signal_identifier'])->toBeNull();
|
||||
expect($p['sms_senders'])->toBe(['79001234567']);
|
||||
expect($p['sms_keyword'])->toBe('KVARTIRA');
|
||||
expect($p['daily_limit_target'])->toBe(8);
|
||||
expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']);
|
||||
});
|
||||
|
||||
test('buildPlan handles sms B3-only (no keyword)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '8101', 'src' => 'mt', 'type' => 'sms', 'content' => '79009998877', 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(1);
|
||||
expect($plan['planned'][0]['sms_senders'])->toBe(['79009998877']);
|
||||
expect($plan['planned'][0]['sms_keyword'])->toBeNull();
|
||||
expect($plan['planned'][0]['platforms'][0]['platform'])->toBe('B3');
|
||||
});
|
||||
|
||||
test('buildPlan skips a group whose Project already exists for the tenant', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79993332211',
|
||||
]);
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '9001', 'src' => 'rt', 'type' => 'calls', 'content' => '79993332211', 'tag' => 'X', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
expect(collect($plan['skipped'])->pluck('reason'))->toContain('already_exists');
|
||||
});
|
||||
|
||||
test('commit creates Project + supplier_projects (external_id from portal) + pivot, no portal write', function (): void {
|
||||
Http::fake(); // ловушка: НИ один HTTP не должен уйти на портал
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$importer = importerWithRows([
|
||||
['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
]);
|
||||
|
||||
$plan = $importer->buildPlan($tenant->id);
|
||||
$result = $importer->commit($plan, $tenant->id);
|
||||
|
||||
expect($result['created_projects'])->toBe(1);
|
||||
|
||||
$project = Project::on('pgsql_supplier')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('signal_identifier', '79991112233')
|
||||
->first();
|
||||
expect($project)->not->toBeNull();
|
||||
expect($project->daily_limit_target)->toBe(18);
|
||||
expect($project->is_active)->toBeTrue();
|
||||
expect($project->regions)->toBe([29]);
|
||||
expect($project->delivery_days_mask)->toBe(31);
|
||||
|
||||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79991112233')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('supplier_external_id')->sort()->values()->all())->toBe(['4001', '4002', '4003']);
|
||||
expect($sps->pluck('sync_status')->unique()->all())->toBe(['ok']);
|
||||
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(6);
|
||||
|
||||
$pivot = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($pivot)->toBe(3);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('commit reuses an existing supplier_project row instead of duplicating', function (): void {
|
||||
Http::fake();
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
// supplier_project уже есть (например, создан webhook resolveOrStub ранее)
|
||||
SupplierProject::on('pgsql_supplier')->forceCreate([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => '79994445566',
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => 'EXIST1',
|
||||
'current_limit' => 6,
|
||||
'current_workdays' => [1, 2, 3, 4, 5],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$importer = importerWithRows([
|
||||
['id' => '4500', 'src' => 'rt', 'type' => 'calls', 'content' => '79994445566', 'tag' => 'Y', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
|
||||
]);
|
||||
$plan = $importer->buildPlan($tenant->id);
|
||||
$importer->commit($plan, $tenant->id);
|
||||
|
||||
// по-прежнему ровно 1 supplier_project с этим ключом+платформой (реюз, не дубль)
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', '79994445566')->where('platform', 'B1')->count())->toBe(1);
|
||||
|
||||
// pivot привязал существующую строку к новому проекту
|
||||
$project = Project::on('pgsql_supplier')->where('signal_identifier', '79994445566')->first();
|
||||
$sp = SupplierProject::on('pgsql_supplier')->where('unique_key', '79994445566')->first();
|
||||
expect(DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('buildPlan unions workdays across platforms with different schedules', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
// B1 = Пн-Ср [1,2,3] → mask 0b0000111 = 7; B2 = Чт-Пт [4,5] → mask 0b0011000 = 24;
|
||||
// union = 31 (Пн-Пт). Тест проверяет реальный OR-merge, не одинаковые расписания.
|
||||
$plan = importerWithRows([
|
||||
['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79992223344', 'tag' => 'W', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3']],
|
||||
['id' => '5002', 'src' => 'bl', 'type' => 'calls', 'content' => '79992223344', 'tag' => 'W', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => ['4', '5']],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(1);
|
||||
expect($plan['planned'][0]['delivery_days_mask'])->toBe(31);
|
||||
});
|
||||
|
||||
test('buildPlan skips sms group when any active row has regions_reverse=true', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$plan = importerWithRows([
|
||||
['id' => '6001', 'src' => 'bl', 'type' => 'sms', 'content' => '79007776655+CODE', 'tag' => 'СМС', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true],
|
||||
['id' => '6002', 'src' => 'mt', 'type' => 'sms', 'content' => '79007776655', 'tag' => 'СМС', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'])->toHaveCount(0);
|
||||
expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude');
|
||||
});
|
||||
|
||||
test('deriveName uses sms sender as fallback when tag is empty', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
// tag='РФ' → попадает в fallback; sms → должен взять sender, а не 'проект'.
|
||||
$plan = importerWithRows([
|
||||
['id' => '7001', 'src' => 'mt', 'type' => 'sms', 'content' => '79001112222', 'tag' => 'РФ', 'lim' => '2', 'status' => true, 'regions' => '', 'workdays' => []],
|
||||
])->buildPlan($tenant->id);
|
||||
|
||||
expect($plan['planned'][0]['name'])->toBe('79001112222');
|
||||
});
|
||||
@@ -80,56 +80,6 @@ it('online mode creates single-group supplier_projects with full regions + pivot
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('online create DIVIDES the limit across B1/B2/B3 so supplier total == project limit (not ×3)', function (): void {
|
||||
// Money-loss regression (owner-reported 2026-05-21, verified live): the limit was
|
||||
// replicated full to all 3 platforms (18 → 18/18/18 = supplier could deliver up to 54).
|
||||
// The portal does NOT divide — each B-project honours its own limit independently.
|
||||
// Fix: split the limit so Σ per-platform == project limit (18 → 6/6/6).
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79991110000',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 18,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$capturedLimits = [];
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$capturedLimits) {
|
||||
$body = $request->data();
|
||||
$capturedLimits[] = $body['limit'] ?? null;
|
||||
|
||||
return Http::response(['status' => 'OK', 'message' => '', 'id' => '3000'], 200);
|
||||
},
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '3001', 'src' => 'rt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
['id' => '3002', 'src' => 'bl', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
['id' => '3003', 'src' => 'mt', 'name' => '79991110000', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991110000'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::where('unique_key', '79991110000')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
// Σ per-platform limits == the project limit — the loss-prevention invariant.
|
||||
expect($sps->sum('current_limit'))->toBe(18);
|
||||
foreach ($sps as $sp) {
|
||||
expect($sp->current_limit)->toBe(6); // 18 / 3 platforms
|
||||
}
|
||||
// Every limit pushed to the portal is the divided share, never the full 18.
|
||||
$sent = array_values(array_filter($capturedLimits, fn ($l) => $l !== null));
|
||||
expect($sent)->not->toBeEmpty();
|
||||
foreach ($sent as $l) {
|
||||
expect((int) $l)->toBe(6);
|
||||
}
|
||||
});
|
||||
|
||||
it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..7])', function (): void {
|
||||
// Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask.
|
||||
// delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5].
|
||||
@@ -211,16 +161,6 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
|
||||
]);
|
||||
}
|
||||
|
||||
// listProjects (dead-donor liveness check) must see the seeded donors as alive,
|
||||
// so the update path runs without recreating (and without hitting the real portal).
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '99B1', 'src' => 'rt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
['id' => '99B2', 'src' => 'bl', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
['id' => '99B3', 'src' => 'mt', 'name' => '79991234567', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79991234567'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock): void {
|
||||
$mock->shouldReceive('updateProject')->times(3)->andReturn(true);
|
||||
});
|
||||
@@ -229,11 +169,9 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
|
||||
|
||||
$sps = SupplierProject::where('unique_key', '79991234567')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
// 9 split across B1/B2/B3 = 3/3/3 (Σ == 9 = project limit, not 9 on each = 27).
|
||||
expect($sps->sum('current_limit'))->toBe(9);
|
||||
foreach ($sps as $sp) {
|
||||
expect($sp->current_workdays)->toBe([1, 2, 3, 4, 5]);
|
||||
expect($sp->current_limit)->toBe(3);
|
||||
expect($sp->current_limit)->toBe(9);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
// Order: 2 projects on one (source × subject) → computeOrder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('order: 2 projects same source×subject → computeOrder([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void {
|
||||
test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
@@ -200,49 +200,19 @@ test('order: 2 projects same source×subject → computeOrder([10,20])=20 split
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20 (the GROUP order), then split
|
||||
// across B1/B2/B3 = 7/7/6 (Σ == 20 — NOT 20 on each = 60, which would be the ×3 overspend).
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20
|
||||
$sp = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->get();
|
||||
->where('platform', 'B1')
|
||||
->first();
|
||||
|
||||
expect($sp)->not->toBeNull();
|
||||
expect($sp->current_limit)->toBe(20);
|
||||
|
||||
// Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately)
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->sum('current_limit'))->toBe(20);
|
||||
expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(7);
|
||||
});
|
||||
|
||||
test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner-reported ×3 bug)', function (): void {
|
||||
// The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the
|
||||
// portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79135161263',
|
||||
'daily_limit_target' => 18,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '4001', 'src' => 'rt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
['id' => '4002', 'src' => 'bl', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
['id' => '4003', 'src' => 'mt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// Assert only THIS group's rows (the nightly job syncs every active project in the DB).
|
||||
$sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79135161263')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->sum('current_limit'))->toBe(18); // Σ == project limit (not 54)
|
||||
expect($sps->sortBy('platform')->pluck('current_limit', 'platform')->all())
|
||||
->toBe(['B1' => 6, 'B2' => 6, 'B3' => 6]); // 18 / 3
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->count())->toBe(3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -27,13 +27,13 @@ test('GET webhook-settings возвращает подписку тенанта'
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/tenants/me/webhook-settings');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
|
||||
expect($response->json('data'))->not->toHaveKey('secret_hash');
|
||||
});
|
||||
@@ -55,11 +55,11 @@ test('GET webhook-settings изолирован по тенанту', function (
|
||||
|
||||
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://93.184.216.34/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
|
||||
expect($response->json('data.secret'))->toStartWith('whsec_');
|
||||
expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();
|
||||
|
||||
@@ -72,15 +72,15 @@ test('PUT webhook-settings обновляет URL существующей по
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://8.8.8.8/hook',
|
||||
'target_url' => 'https://old.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://1.1.1.1/hook',
|
||||
'target_url' => 'https://new.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.target_url'))->toBe('https://1.1.1.1/hook');
|
||||
expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
|
||||
expect($response->json('data'))->not->toHaveKey('secret');
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
});
|
||||
@@ -91,20 +91,12 @@ test('PUT webhook-settings: 422 при не-https URL', function () {
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
});
|
||||
|
||||
test('PUT webhook-settings: 422 для приватного/служебного IP в target_url (SSRF), не сохраняет', function () {
|
||||
$this->putJson('/api/tenants/me/webhook-settings', [
|
||||
'target_url' => 'https://169.254.169.254/hook',
|
||||
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
|
||||
|
||||
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('POST webhooks/test отправляет запрос и возвращает результат', function () {
|
||||
Http::fake(['*' => Http::response(['ok' => true], 200)]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
@@ -112,7 +104,7 @@ test('POST webhooks/test отправляет запрос и возвращае
|
||||
$response->assertOk();
|
||||
expect($response->json('ok'))->toBeTrue();
|
||||
expect($response->json('status'))->toBe(200);
|
||||
Http::assertSent(fn ($req) => $req->url() === 'https://93.184.216.34/hook');
|
||||
Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook');
|
||||
});
|
||||
|
||||
test('POST webhooks/test возвращает ok=false при ошибке endpoint', function () {
|
||||
@@ -120,7 +112,7 @@ test('POST webhooks/test возвращает ok=false при ошибке endpo
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
'target_url' => 'https://crm.example.ru/hook',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/test');
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\WebhookUrlGuard;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
// --- unit: WebhookUrlGuard (IP-литералы, без DNS) ---
|
||||
|
||||
test('WebhookUrlGuard блокирует приватные/зарезервированные/loopback IP', function (string $url) {
|
||||
expect(WebhookUrlGuard::blockReason($url))->not->toBeNull();
|
||||
})->with([
|
||||
'https://127.0.0.1/hook', // loopback
|
||||
'https://10.0.0.1/hook', // private A
|
||||
'https://172.16.0.1/hook', // private B
|
||||
'https://192.168.1.1/hook', // private C
|
||||
'https://169.254.169.254/hook', // link-local / cloud metadata
|
||||
'https://[::1]/hook', // IPv6 loopback
|
||||
]);
|
||||
|
||||
test('WebhookUrlGuard пропускает публичный IP', function () {
|
||||
expect(WebhookUrlGuard::blockReason('https://93.184.216.34/hook'))->toBeNull();
|
||||
});
|
||||
|
||||
test('WebhookUrlGuard отклоняет битый URL', function () {
|
||||
expect(WebhookUrlGuard::blockReason('not-a-url'))->not->toBeNull();
|
||||
});
|
||||
|
||||
// --- endpoint: webhooks/test не должен бить во внутреннюю сеть ---
|
||||
|
||||
test('POST webhooks/test блокирует приватный IP target_url (SSRF) и не шлёт запрос', function () {
|
||||
Http::fake();
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://169.254.169.254/hook',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/test')->assertStatus(422);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('POST webhooks/test пропускает публичный target_url', function () {
|
||||
Http::fake(['*' => Http::response(['ok' => true], 200)]);
|
||||
OutboundWebhookSubscription::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'user_id' => $this->user->id,
|
||||
'target_url' => 'https://93.184.216.34/hook',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/test')
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
@@ -272,3 +272,61 @@ describe('ProjectsView 18:00 cutoff banner', () => {
|
||||
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// 2026-05-22: при total > per_page фронт должен показывать переключатель
|
||||
// страниц (бэкенд уже отдаёт постранично — без UI пользователь видел только
|
||||
// первые 20 проектов из 125).
|
||||
describe('ProjectsView pagination', () => {
|
||||
function makeCard(id: number) {
|
||||
return {
|
||||
id,
|
||||
name: `P${id}`,
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: `${id}.ru`,
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders pagination control when total > per_page', async () => {
|
||||
const items = Array.from({ length: 20 }, (_, i) => makeCard(i + 1));
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { data: items, meta: { total: 125, current_page: 1, per_page: 20 } },
|
||||
});
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="projects-pagination"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT render pagination when total <= per_page', async () => {
|
||||
const items = [makeCard(1), makeCard(2)];
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { data: items, meta: { total: 2, current_page: 1, per_page: 20 } },
|
||||
});
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="projects-pagination"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('changing page triggers refetch with new page param', async () => {
|
||||
const items = Array.from({ length: 20 }, (_, i) => makeCard(i + 1));
|
||||
const mockGet = axios.get as unknown as ReturnType<typeof vi.fn>;
|
||||
mockGet.mockResolvedValue({
|
||||
data: { data: items, meta: { total: 125, current_page: 1, per_page: 20 } },
|
||||
});
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
mockGet.mockClear();
|
||||
// Emulate user picking page 2 via VPagination's model-value.
|
||||
const pagination = wrapper.findComponent({ name: 'VPagination' });
|
||||
expect(pagination.exists()).toBe(true);
|
||||
pagination.vm.$emit('update:modelValue', 2);
|
||||
await flushPromises();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({ params: expect.objectContaining({ page: 2 }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import RegisterView from '../../resources/js/views/auth/RegisterView.vue';
|
||||
|
||||
// Smoke-тесты RegisterView. Ключевое: проверяем что 3-й «маркетинговый» click-wrap
|
||||
// из v8_login.html НЕ присутствует — он противоречит ТЗ §1.5/§4.1 (расхождение #2
|
||||
// handoff vs ТЗ из реестра v1.13). Только два обязательных: оферта + ПДн.
|
||||
// Мокаем API-слой, чтобы тестировать переход шаг1 → шаг2 без сети.
|
||||
vi.mock('../../resources/js/api/auth', () => ({
|
||||
registerStart: vi.fn().mockResolvedValue({ message: 'ok', email: 'manager@yourcompany.ru' }),
|
||||
registerVerify: vi.fn().mockResolvedValue({ user: { id: 1 }, requires_2fa: false }),
|
||||
registerResend: vi.fn().mockResolvedValue({ message: 'ok' }),
|
||||
}));
|
||||
|
||||
const mountRegister = async () => {
|
||||
const router = createRouter({
|
||||
@@ -15,6 +18,7 @@ const mountRegister = async () => {
|
||||
routes: [
|
||||
{ path: '/register', name: 'register', component: RegisterView },
|
||||
{ path: '/login', name: 'login', component: { template: '<div>stub</div>' } },
|
||||
{ path: '/dashboard', name: 'dashboard', component: { template: '<div>stub</div>' } },
|
||||
],
|
||||
});
|
||||
await router.push('/register');
|
||||
@@ -27,43 +31,55 @@ const mountRegister = async () => {
|
||||
describe('RegisterView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Создать аккаунт»', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Создать аккаунт');
|
||||
});
|
||||
|
||||
it('содержит поля email/password с правильным autocomplete', async () => {
|
||||
it('шаг 1 содержит email/телефон/пароль и кнопку «Получить код»', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
expect(wrapper.find('input[type="email"]').attributes('autocomplete')).toBe('email');
|
||||
expect(wrapper.find('input[type="password"]').attributes('autocomplete')).toBe('new-password');
|
||||
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
|
||||
expect(wrapper.find('input[type="tel"]').exists()).toBe(true);
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Получить код');
|
||||
});
|
||||
|
||||
it('телефон форматируется по маске при вводе', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
const tel = wrapper.find('input[type="tel"]');
|
||||
await tel.setValue('89123456789');
|
||||
expect((tel.element as HTMLInputElement).value).toBe('+7 (912) 345-67-89'); // gitleaks:allow
|
||||
});
|
||||
|
||||
it('содержит ровно 2 click-wrap-чекбокса (оферта + ПДн), без маркетингового', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]');
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('оферту');
|
||||
expect(text).toContain('политикой обработки персональных данных');
|
||||
expect(text).not.toContain('информационных сообщений');
|
||||
expect(text).not.toContain('маркетинг');
|
||||
});
|
||||
|
||||
it('содержит ссылку на /login', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
const links = wrapper.findAll('a').map((a) => a.text());
|
||||
expect(links.some((t) => t.includes('Войдите'))).toBe(true);
|
||||
expect(wrapper.text()).toContain('оферту');
|
||||
expect(wrapper.text()).toContain('политикой обработки персональных данных');
|
||||
expect(wrapper.text()).not.toContain('маркетинг');
|
||||
});
|
||||
|
||||
it('A9: переключатель видимости пароля имеет accessible-name и работает', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
const toggle = wrapper.find('[aria-label="Показать пароль"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
expect(toggle.attributes('role')).toBe('button');
|
||||
await toggle.trigger('click');
|
||||
expect(wrapper.find('[aria-label="Скрыть пароль"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
// keyboard activation (Enter) — toggle back
|
||||
await wrapper.find('[aria-label="Скрыть пароль"]').trigger('keydown', { key: 'Enter' });
|
||||
expect(wrapper.find('[aria-label="Показать пароль"]').exists()).toBe(true);
|
||||
it('после заполнения формы и «Получить код» переходит к вводу кода', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
await wrapper.find('input[type="email"]').setValue('manager@yourcompany.ru');
|
||||
await wrapper.find('input[type="tel"]').setValue('9123456789');
|
||||
await wrapper.find('input[type="password"]').setValue('fresh-pass-123');
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]');
|
||||
await checkboxes[0].setValue(true);
|
||||
await checkboxes[1].setValue(true);
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Подтвердить и создать аккаунт');
|
||||
expect(wrapper.text()).toContain('Отправить код повторно');
|
||||
expect(wrapper.find('input[autocomplete="one-time-code"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('../../resources/js/api/client', () => ({
|
||||
|
||||
import {
|
||||
login,
|
||||
register,
|
||||
registerStart,
|
||||
me,
|
||||
logout,
|
||||
verifyTwoFactor,
|
||||
@@ -49,12 +49,13 @@ describe('api/auth', () => {
|
||||
expect(result.user.email).toBe('demo@x.ru');
|
||||
});
|
||||
|
||||
it('register() POSTs /api/auth/register с accept-флагами', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { user: FAKE_USER, requires_2fa: false } });
|
||||
await register({ email: 'a@x.ru', password: 'pw', accept_offer: true, accept_pdn: true });
|
||||
it('registerStart() POSTs /api/auth/register/start с email+phone+accept-флагами', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { message: 'ok', email: 'a@x.ru' } });
|
||||
await registerStart({ email: 'a@x.ru', phone: '79991234567', password: 'pw', accept_offer: true, accept_pdn: true }); // gitleaks:allow
|
||||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/auth/register', {
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/auth/register/start', {
|
||||
email: 'a@x.ru',
|
||||
phone: '79991234567', // gitleaks:allow
|
||||
password: 'pw',
|
||||
accept_offer: true,
|
||||
accept_pdn: true,
|
||||
|
||||
@@ -4,7 +4,9 @@ import { createPinia, setActivePinia } from 'pinia';
|
||||
// Мокаем api/auth до import'а auth-store.
|
||||
vi.mock('../../resources/js/api/auth', () => ({
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
registerStart: vi.fn(),
|
||||
registerVerify: vi.fn(),
|
||||
registerResend: vi.fn(),
|
||||
me: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
verifyTwoFactor: vi.fn(),
|
||||
@@ -131,8 +133,26 @@ describe('useAuthStore', () => {
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('register() → success ставит user', async () => {
|
||||
vi.mocked(authApi.register).mockResolvedValue({
|
||||
it('registerStart() → success возвращает email без изменения user-state', async () => {
|
||||
vi.mocked(authApi.registerStart).mockResolvedValue({ message: 'Код отправлен', email: 'new@example.ru' });
|
||||
|
||||
const auth = useAuthStore();
|
||||
const result = await auth.registerStart({
|
||||
email: 'new@example.ru',
|
||||
phone: '79991234567', // gitleaks:allow
|
||||
password: 'pass1234',
|
||||
accept_offer: true,
|
||||
accept_pdn: true,
|
||||
});
|
||||
|
||||
expect(result.email).toBe('new@example.ru');
|
||||
// user НЕ ставится на шаге 1 — аккаунт ещё не создан.
|
||||
expect(auth.user).toBeNull();
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('registerVerify() → success ставит user + isAuthenticated=true', async () => {
|
||||
vi.mocked(authApi.registerVerify).mockResolvedValue({
|
||||
user: {
|
||||
id: 2,
|
||||
email: 'new@example.ru',
|
||||
@@ -146,12 +166,7 @@ describe('useAuthStore', () => {
|
||||
});
|
||||
|
||||
const auth = useAuthStore();
|
||||
await auth.register({
|
||||
email: 'new@example.ru',
|
||||
password: 'pass1234',
|
||||
accept_offer: true,
|
||||
accept_pdn: true,
|
||||
});
|
||||
await auth.registerVerify('123456');
|
||||
|
||||
expect(auth.user?.email).toBe('new@example.ru');
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { phoneDigits, formatPhone, isValidPhone } from '../../resources/js/utils/phone';
|
||||
|
||||
// Phone numbers below are test fixtures, not real PII. gitleaks:allow
|
||||
describe('phone utils', () => {
|
||||
it('phoneDigits нормализует к 7XXXXXXXXXX', () => {
|
||||
expect(phoneDigits('+7 (912) 345-67-89')).toBe('79123456789'); // gitleaks:allow
|
||||
expect(phoneDigits('8 912 345 67 89')).toBe('79123456789'); // gitleaks:allow
|
||||
expect(phoneDigits('9123456789')).toBe('79123456789'); // gitleaks:allow
|
||||
expect(phoneDigits('')).toBe('');
|
||||
});
|
||||
|
||||
it('formatPhone строит маску прогрессивно', () => {
|
||||
expect(formatPhone('')).toBe('');
|
||||
expect(formatPhone('7912')).toBe('+7 (912');
|
||||
expect(formatPhone('79123456789')).toBe('+7 (912) 345-67-89'); // gitleaks:allow
|
||||
// лишние цифры обрезаются до 11
|
||||
expect(formatPhone('791234567890000')).toBe('+7 (912) 345-67-89'); // gitleaks:allow
|
||||
});
|
||||
|
||||
it('isValidPhone true только для полного 7+10', () => {
|
||||
expect(isValidPhone('+7 (912) 345-67-89')).toBe(true); // gitleaks:allow
|
||||
expect(isValidPhone('7912345')).toBe(false);
|
||||
expect(isValidPhone('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\PhoneNormalizer;
|
||||
|
||||
test('нормализует разные форматы RU-номера в 7XXXXXXXXXX', function (string $input, ?string $expected) {
|
||||
expect(PhoneNormalizer::normalize($input))->toBe($expected);
|
||||
})->with([
|
||||
'маска +7' => ['+7 (912) 345-67-89', '79123456789'],
|
||||
'через 8' => ['8 (912) 345-67-89', '79123456789'],
|
||||
'голые 7+10' => ['79123456789', '79123456789'],
|
||||
'голые 10' => ['9123456789', '79123456789'],
|
||||
'с мусором' => ['тел: +7-912-345-67-89 ', '79123456789'],
|
||||
'слишком коротко' => ['12345', null],
|
||||
'слишком длинно' => ['791234567890123', null],
|
||||
'пусто' => ['', null],
|
||||
]);
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Pd\ImpersonationAuditService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->adminId = DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'admin-imp-'.uniqid().'@liderra.ru',
|
||||
'full_name' => 'SaaS Admin',
|
||||
'password_hash' => '$2y$04$dummy-hash-for-test',
|
||||
'role' => 'support',
|
||||
'is_active' => true,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
$this->token = ImpersonationToken::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'requested_by' => $this->adminId,
|
||||
'code_hash' => 'h',
|
||||
'reason' => 'support case '.str_repeat('x', 30),
|
||||
'sent_to_email' => 'a@b.ru',
|
||||
'expires_at' => now()->addMinutes(15),
|
||||
]);
|
||||
});
|
||||
|
||||
it('recordInit writes saas_admin_audit_log action=impersonation.init', function () {
|
||||
app(ImpersonationAuditService::class)->recordInit($this->token, adminId: $this->adminId, ip: '1.2.3.4');
|
||||
$row = DB::table('saas_admin_audit_log')->where('action', 'impersonation.init')->latest('id')->first();
|
||||
expect($row)->not->toBeNull()
|
||||
->and((int) $row->target_id)->toBe($this->tenant->id)
|
||||
->and($row->reason)->toBe($this->token->reason);
|
||||
});
|
||||
|
||||
it('recordVerify writes BOTH saas_audit and pd_processing_log', function () {
|
||||
app(ImpersonationAuditService::class)->recordVerify($this->token, adminId: $this->adminId, ip: '1.2.3.4');
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.verify')->count())->toBe(1)
|
||||
->and(DB::table('pd_processing_log')
|
||||
->where('action', 'viewed')
|
||||
->where('purpose', 'impersonation_session_'.$this->token->id)
|
||||
->where('actor_admin_user_id', $this->adminId)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('recordEnd writes saas_admin_audit_log action=impersonation.end', function () {
|
||||
app(ImpersonationAuditService::class)->recordEnd($this->token, adminId: $this->adminId, ip: '1.2.3.4');
|
||||
expect(DB::table('saas_admin_audit_log')->where('action', 'impersonation.end')->count())->toBe(1);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, DatabaseTransactions::class);
|
||||
|
||||
it('inserts pd_processing_log row with all fields', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'viewed', subjectType: 'lead', subjectId: 123,
|
||||
purpose: 'lead_card_view', tenantId: $tenant->id,
|
||||
actorTenantUserId: $user->id, actorAdminUserId: null, ip: '10.0.0.1',
|
||||
);
|
||||
|
||||
$row = DB::table('pd_processing_log')->latest('id')->first();
|
||||
expect($row->action)->toBe('viewed')
|
||||
->and($row->subject_type)->toBe('lead')
|
||||
->and((int) $row->subject_id)->toBe(123)
|
||||
->and((int) $row->actor_tenant_user_id)->toBe($user->id)
|
||||
->and((string) $row->ip_address)->toBe('10.0.0.1');
|
||||
});
|
||||
|
||||
it('allows system actor (both NULL) per chk_pd_actor', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$before = DB::table('pd_processing_log')->count();
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'exported', subjectType: 'lead', subjectId: null,
|
||||
purpose: 'cron_cleanup', tenantId: $tenant->id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
expect(DB::table('pd_processing_log')->count())->toBe($before + 1);
|
||||
});
|
||||
|
||||
it('rejects two-actor row (chk_pd_actor violation)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
|
||||
expect(fn () => app(PdAuditLogger::class)->record(
|
||||
action: 'viewed', subjectType: 'lead', subjectId: 1,
|
||||
purpose: 'x', tenantId: $tenant->id,
|
||||
actorTenantUserId: $user->id, actorAdminUserId: 999999, ip: null,
|
||||
))->toThrow(QueryException::class);
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\Import\SupplierImportMapper;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
test('platformFromSrc maps rt/bl/mt to B1/B2/B3, others null', function (): void {
|
||||
expect(SupplierImportMapper::platformFromSrc('rt'))->toBe('B1');
|
||||
expect(SupplierImportMapper::platformFromSrc('bl'))->toBe('B2');
|
||||
expect(SupplierImportMapper::platformFromSrc('mt'))->toBe('B3');
|
||||
expect(SupplierImportMapper::platformFromSrc('dop2'))->toBeNull();
|
||||
expect(SupplierImportMapper::platformFromSrc(''))->toBeNull();
|
||||
});
|
||||
|
||||
test('signalTypeFromType maps calls/hosts/sms', function (): void {
|
||||
expect(SupplierImportMapper::signalTypeFromType('calls'))->toBe('call');
|
||||
expect(SupplierImportMapper::signalTypeFromType('hosts'))->toBe('site');
|
||||
expect(SupplierImportMapper::signalTypeFromType('sms'))->toBe('sms');
|
||||
expect(SupplierImportMapper::signalTypeFromType('unknown'))->toBeNull();
|
||||
});
|
||||
|
||||
test('parseGibddRegions splits comma/space string of codes; empty → []', function (): void {
|
||||
expect(SupplierImportMapper::parseGibddRegions('24'))->toBe([24]);
|
||||
expect(SupplierImportMapper::parseGibddRegions('24,77'))->toBe([24, 77]);
|
||||
expect(SupplierImportMapper::parseGibddRegions('24, 77 78'))->toBe([24, 77, 78]);
|
||||
expect(SupplierImportMapper::parseGibddRegions(''))->toBe([]);
|
||||
expect(SupplierImportMapper::parseGibddRegions(null))->toBe([]);
|
||||
});
|
||||
|
||||
test('workdaysToMask converts string day list to bitmask (bit0=Mon)', function (): void {
|
||||
expect(SupplierImportMapper::workdaysToMask(['1', '2', '3', '4', '5']))->toBe(31);
|
||||
expect(SupplierImportMapper::workdaysToMask(['1', '2', '3', '4', '5', '6', '7']))->toBe(127);
|
||||
expect(SupplierImportMapper::workdaysToMask([]))->toBe(127);
|
||||
});
|
||||
|
||||
test('parseSmsContent splits sender+keyword; sender-only when no plus', function (): void {
|
||||
expect(SupplierImportMapper::parseSmsContent('79001234567+KVARTIRA'))
|
||||
->toBe(['sender' => '79001234567', 'keyword' => 'KVARTIRA']);
|
||||
expect(SupplierImportMapper::parseSmsContent('79001234567'))
|
||||
->toBe(['sender' => '79001234567', 'keyword' => null]);
|
||||
expect(SupplierImportMapper::parseSmsContent(''))
|
||||
->toBe(['sender' => '', 'keyword' => null]);
|
||||
});
|
||||
@@ -24,37 +24,6 @@ it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (
|
||||
'empty' => [[], 0],
|
||||
]);
|
||||
|
||||
// distributeForPlatform: split the group order across N supplier platforms so the
|
||||
// SUM of per-platform limits == order (portal does NOT divide — verified live 2026-05-21,
|
||||
// each B1/B2/B3 honors its own limit independently → must split ourselves). Largest-remainder.
|
||||
|
||||
it('distributeForPlatform splits order so per-platform limits sum to the order', function (array $platforms, int $order, array $expected): void {
|
||||
expect(SupplierQuotaAllocator::distributeForPlatform($order, $platforms))->toBe($expected);
|
||||
})->with([
|
||||
// Even split (the common case — the owner reported 18 → 18/18/18 instead of 6/6/6)
|
||||
'call/site 18→6/6/6' => [['B1', 'B2', 'B3'], 18, ['B1' => 6, 'B2' => 6, 'B3' => 6]],
|
||||
'call/site 24→8/8/8' => [['B1', 'B2', 'B3'], 24, ['B1' => 8, 'B2' => 8, 'B3' => 8]],
|
||||
'call/site 3→1/1/1' => [['B1', 'B2', 'B3'], 3, ['B1' => 1, 'B2' => 1, 'B3' => 1]],
|
||||
// Uneven split — largest remainder: leading platforms get the +1, sum stays exact
|
||||
'call/site 10→4/3/3' => [['B1', 'B2', 'B3'], 10, ['B1' => 4, 'B2' => 3, 'B3' => 3]],
|
||||
'call/site 20→7/7/6' => [['B1', 'B2', 'B3'], 20, ['B1' => 7, 'B2' => 7, 'B3' => 6]],
|
||||
// SMS+keyword (2 platforms)
|
||||
'sms+kw 5→3/2' => [['B2', 'B3'], 5, ['B2' => 3, 'B3' => 2]],
|
||||
'sms+kw 2→1/1' => [['B2', 'B3'], 2, ['B2' => 1, 'B3' => 1]],
|
||||
// SMS without keyword (1 platform) — no split, full order
|
||||
'sms 7→7' => [['B3'], 7, ['B3' => 7]],
|
||||
// Edge: zero order
|
||||
'zero' => [['B1', 'B2', 'B3'], 0, ['B1' => 0, 'B2' => 0, 'B3' => 0]],
|
||||
]);
|
||||
|
||||
it('distributeForPlatform always conserves the order (sum invariant)', function (int $order, int $count): void {
|
||||
$platforms = array_slice(['B1', 'B2', 'B3'], 0, $count);
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $platforms);
|
||||
expect(array_sum($shares))->toBe($order);
|
||||
})->with([
|
||||
[1, 3], [2, 3], [7, 3], [13, 3], [100, 3], [101, 2], [99, 1], [0, 3],
|
||||
]);
|
||||
|
||||
// Orthogonal smoke tests on allocate() — preserved from pre-T3 coverage; assert
|
||||
// invariants independent of the order formula (workdays/regions union, null-on-no-eligible).
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\SupplierRegions;
|
||||
use Tests\TestCase;
|
||||
|
||||
// Бутстрапим приложение — mapToSupplier() пишет Log::warning при отбросе непереводимых.
|
||||
uses(TestCase::class);
|
||||
|
||||
// Regression: Лидерра нумерует субъекты по конституционному порядку (RussianRegions,
|
||||
// Красноярский=29), поставщик crm.bp-gr.ru — по автокодам ГИБДД (Красноярский=24,
|
||||
// Архангельск=29). Sync слал Лидерра-код как есть → у поставщика выбирался ЧУЖОЙ регион.
|
||||
// SupplierRegions::mapToSupplier переводит Лидерра-код → код поставщика.
|
||||
|
||||
it('translates Liderra constitutional codes to supplier (ГИБДД) codes', function (): void {
|
||||
expect(SupplierRegions::mapToSupplier([29]))->toBe([24]); // Красноярский край
|
||||
expect(SupplierRegions::mapToSupplier([35]))->toBe([29]); // Архангельская обл.
|
||||
expect(SupplierRegions::mapToSupplier([24]))->toBe([21]); // Чувашская Республика
|
||||
expect(SupplierRegions::mapToSupplier([82]))->toBe([77]); // Москва
|
||||
expect(SupplierRegions::mapToSupplier([83]))->toBe([78]); // Санкт-Петербург
|
||||
});
|
||||
|
||||
it('returns empty for all-Russia (no regions)', function (): void {
|
||||
expect(SupplierRegions::mapToSupplier([]))->toBe([]);
|
||||
});
|
||||
|
||||
it('ignores sentinel 0 (Вся РФ)', function (): void {
|
||||
expect(SupplierRegions::mapToSupplier([0]))->toBe([]);
|
||||
});
|
||||
|
||||
it('drops regions the supplier does not offer', function (): void {
|
||||
// Поставщик НЕ предлагает: Московская (56), Ленинградская (53), Крым (13), новые территории.
|
||||
expect(SupplierRegions::mapToSupplier([56]))->toBe([]); // Московская обл.
|
||||
expect(SupplierRegions::mapToSupplier([53]))->toBe([]); // Ленинградская обл.
|
||||
expect(SupplierRegions::mapToSupplier([13]))->toBe([]); // Крым
|
||||
// mixed: оставляем переводимые, отбрасываем непереводимые
|
||||
expect(SupplierRegions::mapToSupplier([29, 56]))->toBe([24]); // Красноярский kept, Московская dropped
|
||||
});
|
||||
|
||||
it('dedupes and sorts supplier codes', function (): void {
|
||||
// 35→29 (Архангельск), 29→24 (Красноярский), дубль 35 → unique+sorted [24,29]
|
||||
expect(SupplierRegions::mapToSupplier([35, 29, 35]))->toBe([24, 29]);
|
||||
});
|
||||
|
||||
it('every map entry points to a distinct supplier code (no collisions)', function (): void {
|
||||
$targets = array_values(SupplierRegions::LIDERRA_TO_SUPPLIER);
|
||||
expect(count($targets))->toBe(count(array_unique($targets)));
|
||||
});
|
||||
|
||||
test('mapFromSupplier inverts LIDERRA_TO_SUPPLIER bijection', function (): void {
|
||||
// ГИБДД 24 → Лидерра 29 (Красноярский); ГИБДД 77 → Лидерра 82 (Москва)
|
||||
expect(SupplierRegions::mapFromSupplier([24]))->toBe([29]);
|
||||
expect(SupplierRegions::mapFromSupplier([77]))->toBe([82]);
|
||||
});
|
||||
|
||||
test('mapFromSupplier maps multiple codes, sorted ascending, deduped', function (): void {
|
||||
// ГИБДД 77→82 (Москва), 78→83 (СПб), 24→29 (Красноярский)
|
||||
expect(SupplierRegions::mapFromSupplier([78, 24, 77, 24]))->toBe([29, 82, 83]);
|
||||
});
|
||||
|
||||
test('mapFromSupplier drops unknown supplier codes', function (): void {
|
||||
// 999 нет в карте → отброшен; 24 → 29
|
||||
expect(SupplierRegions::mapFromSupplier([999, 24]))->toBe([29]);
|
||||
});
|
||||
|
||||
test('mapFromSupplier returns [] for empty input', function (): void {
|
||||
expect(SupplierRegions::mapFromSupplier([]))->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('extracts root domain from subdomain', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('krasnoyarsk.carmoney.ru'))->toBe('carmoney.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('client.carmoney.ru'))->toBe('carmoney.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('next.vashinvestor.ru'))->toBe('vashinvestor.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('cabinet.caranga.ru'))->toBe('caranga.ru');
|
||||
});
|
||||
|
||||
it('returns null for already-root domain', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('carmoney.ru'))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain('заложитьптс.рф'))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-domain identifiers', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('7800XXXXXXX'))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain(''))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain(' '))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain('TINKOFF'))->toBeNull();
|
||||
});
|
||||
+24
-31
@@ -1,6 +1,17 @@
|
||||
# Глоссарий проекта Лидерра
|
||||
# Формат: одно слово на строке. Кириллица в нижнем регистре.
|
||||
|
||||
# Test-deploy Yandex Cloud (2026-05-21)
|
||||
hba
|
||||
htpasswd
|
||||
lsb
|
||||
nslookup
|
||||
scp
|
||||
хостить
|
||||
tos
|
||||
прода
|
||||
ребута
|
||||
|
||||
# A4 design-tooling integration (v2.8 / v3.8 / v1.22)
|
||||
iconify
|
||||
|
||||
@@ -1588,39 +1599,21 @@ lemed
|
||||
батч
|
||||
ретраит
|
||||
шеринге
|
||||
unactivated
|
||||
|
||||
# Серверный слой защиты SEC-1..7 (2026-05-22)
|
||||
бэкапа
|
||||
баны
|
||||
алертинг
|
||||
алертингом
|
||||
htpasswd
|
||||
ignoreip
|
||||
libnginx
|
||||
crs
|
||||
coraza
|
||||
usr
|
||||
|
||||
# ПИЛОТ.md эксплуатационные термины (2026-05-22)
|
||||
ротирован
|
||||
разлогинятся
|
||||
крэше
|
||||
стектрейсы
|
||||
закэширован
|
||||
scp
|
||||
крашей
|
||||
PGDG
|
||||
лок
|
||||
SMTPS
|
||||
юните
|
||||
бакет
|
||||
MTA
|
||||
алиас
|
||||
волатилен
|
||||
синке
|
||||
# Supplier dead-donor fix + баннер 18:00 (2026-05-21)
|
||||
дрейфнувшей
|
||||
дропа
|
||||
коммитах
|
||||
доустановлены
|
||||
дочерпывание
|
||||
creds
|
||||
незавершёнку
|
||||
субдомен
|
||||
субдомена
|
||||
субдомены
|
||||
субдомена
|
||||
брейнсторминг
|
||||
брейнсторминга
|
||||
гэп
|
||||
артизан
|
||||
Артизан
|
||||
Sps
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.21)
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.19)
|
||||
|
||||
**Дата:** 21.05.2026
|
||||
**Дата:** 19.05.2026
|
||||
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3). **17 правил R0–R16** (R15 off-phase routing введён в v3.14 на освободившийся после v2.0 R15-motion слот; R16 brain evidence loop введён в v3.16).
|
||||
|
||||
**v3.21** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco) → в R10.1 Блок 1 note (Ward) + Блок 3 (ZAP MCP-row) снят статус PENDING INSTALL. Содержательных изменений R0–R16: 0; счётчики/состав без изменений. Связано: Tooling v2.21, Pravila v1.38, CLAUDE.md v2.25; setup-доки `docs/security/{zap,ward}-setup.md`; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
**v3.20** — A8 infosec-tooling: R10.1 Блок 1 note +infosec-tooling (#69 Nuclei + #70 Ward — CLI-бинари; #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live — self-authored project-скилы) + Блок 3 +OWASP ZAP MCP (#68, PENDING INSTALL — нет Java). Nuclei установлен+verified (CLI, не MCP); Ward заменил Enlightn (abandoned/L13), PENDING INSTALL — нет Go. Каждый внешний инструмент прошёл провенанс-вет IS9 ДО установки (риск ToxicSkills). Новая 17-я off-phase подкатегория infosec-tooling, раздел A8 карты. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.20, Pravila v1.37, CLAUDE.md v2.24, ADR-014 (IS1–IS9); план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
**v3.19** — A1 backend-tooling: R10.1 Блок 1 note +backend-tooling (#64 Rector + #65 PHP Insights — Composer dev-deps; #66 laravel-backend-patterns — self-authored project-скил; #67 NightOwl — DEFERRED, MCP при активации). Новая 16-я off-phase подкатегория backend-tooling, раздел A1 карты. R15.6 +backend-tooling в список категорий. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.19, Pravila v1.35, CLAUDE.md v2.22, ADR-013; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
|
||||
|
||||
**v3.18** — finance-tooling (C6+C7): R10.1 Блок 1 +finance plugin (#61, marketplace `finance@knowledge-work-plugins`, homed C7, cross-ref C6) + note (+billing-audit #62 / ru-tax-accounting #63 — self-authored project-скилы). Новая 15-я off-phase подкатегория finance-tooling, разделы C6/C7 карты. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.18, Pravila v1.34, CLAUDE.md v2.21, ADR-012; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
@@ -463,8 +459,6 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
|
||||
**Блок 1 — note (v3.19):** **Rector** (Tooling #64) + **PHP Insights** (Tooling #65) — Composer dev-dependencies (`rector/rector` + `driftingly/rector-laravel`; `nunomaduro/phpinsights`), **не** marketplace-плагины и **не** в `enabledPlugins` (как deptrac #43 / promptfoo #48). CLI-инструменты: Rector — авто-рефакторинг/version-upgrade (`composer rector`/`rector:fix`), manual/CI, dry-run baseline 16 файлов → **не** блокирующий lefthook; PHP Insights — метрики complexity/architecture (`composer insights`), on-demand/CI с порогами → **не** блокирующий (BT9). **laravel-backend-patterns** (Tooling #66) — self-authored project-скил в `.claude/skills/laravel-backend-patterns/`, **линтуется** (LINT1, как billing-audit/process-*). **NightOwl** (Tooling #67) — `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent`, **DEFERRED** (native-Windows нет pcntl/posix; OSS без MCP; hosted 152-ФЗ); при активации (Linux/Б-1) — MCP в Блок 3 или Boost `database-query`. Категория **backend-tooling** (16-я off-phase подкатегория, раздел A1 карты), вне R6.0/R6.1/R14. ADR-013.
|
||||
|
||||
**Блок 1 — note (v3.20):** **Nuclei** (Tooling #69) + **Ward** (Tooling #70) — CLI-бинари (как deptrac #43 / gitleaks / squawk), **не** marketplace-плагины и **не** в `enabledPlugins`. Nuclei (`projectdiscovery/nuclei` v3.8.0, MIT, Go) — `bin/nuclei.exe`, **установлен+verified**; широкое сканирование известных уязвимостей; **CLI, не MCP** (nuclei не говорит на MCP → нет Блока 3 / l1-watcher alias). Ward (`Eljakani/ward`, MIT, Go) — безопасность настроек Laravel; **ЗАМЕНИЛ Enlightn** (abandoned/L13); **установлен 21.05** портативно (собран portable Go → `bin/ward.exe` v0.4.1, `docs/security/ward-setup.md`). **pdn-152fz-audit** (#71) + **threat-model** (#72) + **security-go-live** (#73) — self-authored project-скилы в `.claude/skills/`, **линтуются** (LINT1, как billing-audit/process-*). Каждый внешний инструмент прошёл провенанс-вет IS9 (`docs/security/infosec-vet.md`) ДО установки (риск ToxicSkills). Категория **infosec-tooling** (17-я off-phase подкатегория, раздел A8 карты), вне R6.0/R6.1/R14. ADR-014 (IS1–IS9).
|
||||
|
||||
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
|
||||
|
||||
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
|
||||
@@ -499,7 +493,6 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
| **openapi-mcp-server** *(`openapi` сервер, tools `mcp__openapi__*`)* | `.mcp.json` (stdio MCP, env `OPENAPI_SPEC_URL` или локальный файл) | **integration-tooling MCP** — OpenAPI/Swagger-спецификации интеграций (inspect, introspect внешних API). Категория: **integration-tooling** (Tooling §4.22 #47). Раздел A3 карты «Программирование — интеграции (API, вебхуки)». Off-phase | при работе с внешними API-интеграциями (introspection спецификаций). **READ-ONLY introspection** — не мутировать внешние API из Claude. Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline UI-генераторов. Вне R6/R14 |
|
||||
| **Jupyter MCP** *(`jupyter` сервер)* — **DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: Python ML-окружение | **ml-ai-tooling MCP** — исполняемые ноутбуки (классический ML: обучение моделей). Категория: **ml-ai-tooling** (Tooling §4.25 #50). Раздел A11 карты «ML / AI-разработка». Off-phase | DEFERRED — на native-Windows машине нет Python ML-рантайма и нет модели для обучения. Зарегистрирован как pending-слот (как Figma MCP); устанавливается отдельной severable-задачей при появлении конкретной модели. Вне R6/R14 |
|
||||
| **n8n-mcp** *(`n8n` сервер)* — **DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: принятие n8n в стек портала | **business-process MCP** — workflow-движок платформы n8n (построение/запуск автоматизированных workflow). Категория: **business-process** (Tooling §4.29 #54). Раздел C10 карты «Бизнес-процессы (общее)». Off-phase | DEFERRED — стек Лидерры не содержит n8n (движок процессов = очередь Laravel + события/джобы); принятие n8n как инфраструктуры — отдельное архитектурное решение (свой ADR), не выбор инструмента (N8N1). Зарегистрирован как pending-слот (как Figma MCP / Jupyter MCP); устанавливается отдельной severable-задачей. Вне R6/R14 |
|
||||
| **OWASP ZAP MCP** *(`zap` сервер, официальный ZAP «MCP Integration» add-on)* — **установлен 21.05** | `bin/ZAP_2.17.0/` + MCP-аддон `mcp-alpha-0.0.1` на portable Temurin JRE 17 (`bin/_runtimes/`, без choco); MCP-эндпоинт (SSE) регистрируется в `.mcp.json` при запущенном ZAP-демоне (`docs/security/zap-setup.md`) | **infosec-tooling MCP** — глубокая боевая DAST работающего портала (spider + active scan: обход входа, инъекции, XSS). Категория: **infosec-tooling** (Tooling §4.43 #68). Раздел A8 карты. Off-phase | Установлен (daemon API verified → 2.17.0); MCP-аддон alpha. Цель по умолчанию **локальная копия** (127.0.0.1), бой — только по явной команде (IS8). READ-only сканер. Провенанс OWASP/Checkmarx (IS9-вет). Не trigger'ит R6.0/R6.1 и не входит в R14 pipeline. Вне R6/R14. ADR-014 |
|
||||
|
||||
**Отмена:** через удаление из `~/.claude.json` или `.mcp.json`. Live-override через `/команду` для MCP не предусмотрен — MCP-серверы не имеют slash-интерфейса.
|
||||
|
||||
@@ -832,7 +825,7 @@ Pravila §12 (Superpowers инвокация первой), §14 (queen-роут
|
||||
- **UI-пул** (#31 UPM, #32 21st) — здесь R15 не применяется; R14 pipeline ведёт (это UI-задачи по природе).
|
||||
- **infrastructure** (#33 claude-md-management) — единственный канал для правок CLAUDE.md (Pravila §5 п.10 + R10.1 Блок 1).
|
||||
- **authoring-tooling** (#56-#58) — политика триггеров: skill-creator ≥3 повторений workflow → новый скил; hookify повторяющаяся ошибка → новый хук (с pre-check HK1); plugin-dev — для расширений plugin-grain.
|
||||
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support / finance-tooling / backend-tooling / infosec-tooling** — следуют routing-off-phase.md.
|
||||
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support / finance-tooling / backend-tooling** — следуют routing-off-phase.md.
|
||||
|
||||
### 15.7. Тип правила и enforcement
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.38 (21.05.2026)
|
||||
**Дата:** 21.05.2026
|
||||
**Версия:** v1.35 (20.05.2026)
|
||||
**Дата:** 20.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.38 относительно v1.37:** A8 infosec install-sync — ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco, по выбору заказчика «оба портативно») → в §13.2 абзаце «Off-phase infosec-tooling» статус **PENDING INSTALL снят** для обоих (ZAP: ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17; Ward: собран portable Go → `bin/ward.exe` v0.4.1); setup-доки `docs/security/{zap,ward}-setup.md`. Архитектурных изменений §§1–16: 0. Связано: Tooling v2.21, PSR_v1 v3.21, CLAUDE.md v2.25; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
**Что изменилось в v1.37 относительно v1.36:** A8 infosec-tooling — §13.2 +абзац «Off-phase infosec-tooling»: #68 OWASP ZAP (MCP DAST, **PENDING INSTALL** — нет Java), #69 Nuclei (CLI, установлен+verified), #70 Ward (CLI, заменил abandoned Enlightn, **PENDING INSTALL** — нет Go), #71 pdn-152fz-audit + #72 threat-model + #73 security-go-live (self-authored project-скилы). 17-я off-phase подкатегория, раздел A8. Провенанс-вет IS9 каждого внешнего ДО установки (риск ToxicSkills). Серверный слой (WAF/DDoS/мониторинг и т.д.) — out of scope, открытые вопросы SEC-1..SEC-7 (Б-1). Не UI → вне R6.0/R6.1/R14. Границы — ADR-014 (IS1–IS9). Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.20, PSR_v1 v3.20, CLAUDE.md v2.24; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`. **NB:** перенумеровано v1.36→v1.37 при ребейзе на origin/main — v1.36 параллельно занят observer missed-activations.
|
||||
|
||||
**Что изменилось в v1.36 относительно v1.35:** §16.4 расширен симметрией missed activation (условное правило): §16.4 заголовок уточнён «(условное)»; тело расширено — поведенческое правило теперь содержит условие «если профильной задачи в эпизодах не было»; добавлено **симметричное правило (missed activation)**: эпизоды с профильной классификацией без активации релевантного non-dormant узла — сигнал, surface в STATUS.md (C5: `missed_activations: N`, ⚠️ при N>0) и в выводе `/brain-retro`, не блок коммита; хранение mapping в `tools/observer-classification-map.json` + `tools/.node-dormancy.json` (двойной сигнал dormant=true ИЛИ DEFERRED в boundaries); DEFERRED-узлы (#17/#44/#50/#54/#67) — в missed activations не учитываются. Архитектурных изменений в §§1–15: 0. Связано: план `docs/superpowers/plans/2026-05-21-observer-missed-activations.md`.
|
||||
|
||||
**Что изменилось в v1.35 относительно v1.34:** A1 backend-tooling — §13.2 +абзац «Off-phase backend-tooling»: #64 Rector + rector-laravel (Composer dev-dep, авто-рефакторинг/version-upgrade, manual/CI — dry-run baseline 16 файлов, не блокирующий), #65 PHP Insights (Composer dev-dep, метрики complexity/architecture, on-demand/CI — не блокирующий), #66 laravel-backend-patterns (self-authored project-скил, backend-конвенции Лидерры), #67 NightOwl (self-hosted runtime-телеметрия — **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ). 16-я off-phase подкатегория, раздел A1. Не UI → вне R6.0/R6.1/R14. Границы — ADR-013. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.19, PSR_v1 v3.19, CLAUDE.md v2.22; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
|
||||
|
||||
**Что изменилось в v1.34 относительно v1.33:** finance-tooling (C6+C7) — §13.2 +абзац «Off-phase finance-tooling»: #61 finance plugin (marketplace `finance@knowledge-work-plugins`, Anthropic Verified, homed C7, cross-ref C6; РФ-применимость частична — US-GAAP-скилы ⚠️, SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 billing-audit (self-authored project-скил, C6 — денежные инварианты биллинга), #63 ru-tax-accounting (self-authored project-скил, C7 — РСБУ/НК РФ). 15-я off-phase подкатегория. Не UI → вне R6.0/R6.1/R14. Границы — ADR-012. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.18, PSR_v1 v3.18, CLAUDE.md v2.21; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
@@ -772,8 +766,6 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
|
||||
|
||||
**Off-phase backend-tooling (A1, v1.35, 20.05.2026):** Инструменты раздела A1 карты «Программирование — backend» — #64 `Rector` + `rector-laravel` (Tooling §4.39; Composer dev-dependencies `rector/rector` + `driftingly/rector-laravel`, авто-рефакторинг/version-upgrade; конфиг `app/rector.php` deadCode+codeQuality conservative; постура manual/CI `composer rector`/`rector:fix` — dry-run baseline 16 файлов → **не** блокирующий lefthook, прецедент promptfoo ML1), #65 `PHP Insights` (Tooling §4.40; Composer dev-dependency `nunomaduro/phpinsights`; метрики complexity/architecture; конфиг `app/config/insights.php` — SyntaxCheck removed из-за Windows subprocess-краша, style-ось off — владелец Pint, BT4; постура on-demand/CI `composer insights` с порогами → **не** блокирующий, BT9), #66 `laravel-backend-patterns` (Tooling §4.41; self-authored project-скил `.claude/skills/laravel-backend-patterns/` — backend-конвенции Лидерры: слоистость/RLS-aware/bcmath-деньги/идемпотентность/partition-aware; **линтуется**, LINT1), #67 `NightOwl` (Tooling §4.42; `laravel/nightwatch` + self-hosted `lemed99/nightowl-agent` — коррелированный runtime-трейс; **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ; pending Б-1/Linux). Плюс reuse существующих узлов A1 (Boost #10, Pint #11, Larastan #12). **Шестнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Rector/PHP Insights **не гейтят коммит** (manual/CI — избегаем дубля с Pint/Larastan/deptrac + авто-мутации кода). Границы — ADR-013 (BT1–BT9). Регулируется PSR_v1 R10.1 Блок 1 note. Установлено 20.05.2026 на ветке `worktree-a1-backend-tooling`; план `docs/superpowers/plans/2026-05-20-a1-backend-tooling.md`.
|
||||
|
||||
**Off-phase infosec-tooling (A8, v1.38, 21.05.2026):** Инструменты раздела A8 карты «Информационная безопасность» — портал готовится к публичному запуску в интернете. #68 `OWASP ZAP` (Tooling §4.43; официальный ZAP «MCP Integration» add-on `zaproxy/zap-extensions`, Apache-2.0; глубокая боевая DAST — обход входа, инъекции, XSS; MCP-сервер; **установлен 21.05** портативно — ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17, без choco, `docs/security/zap-setup.md`; цель по умолчанию локальная 127.0.0.1, бой только по явной команде — IS8), #69 `Nuclei` (Tooling §4.44; `projectdiscovery/nuclei` v3.8.0 MIT, Go-бинарь `bin/nuclei.exe` — широкая проверка известных уязвимостей/экспозиции/TLS; **CLI, не MCP**; **установлен+verified** на живом портале; квирки native-Windows: цель `127.0.0.1` не `localhost`, низкий rate-limit для однопоточного dev-сервера), #70 `Ward` (Tooling §4.45; `Eljakani/ward` MIT, Go CLI — безопасность настроек Laravel: .env/config/заголовки/cookie/secrets/deps; **ЗАМЕНИЛ Enlightn** — тот abandoned + без поддержки Laravel 13; **установлен 21.05** портативно — собран portable Go → `bin/ward.exe` v0.4.1, без choco, `docs/security/ward-setup.md`), #71 `pdn-152fz-audit` + #72 `threat-model` + #73 `security-go-live` (Tooling §4.46-4.48; self-authored project-скилы `.claude/skills/` — аудит ПДн+соответствие 152-ФЗ / STRIDE-моделирование угроз going-public / go-live security-gate оркестратор; **линтуются**, LINT1). Каждый внешний инструмент прошёл провенанс-вет IS9 (`docs/security/infosec-vet.md`) ДО установки (риск ToxicSkills ≈13% security-скилов с дефектами). **Семнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. Серверный слой защиты (WAF / anti-brute-force / DDoS / мониторинг вторжений / secrets-vault / TLS-HSTS-CSP / бэкапы+IR-runbook) — **out of scope**, открытые вопросы инфраструктуры (привязка к Б-1, SEC-1..SEC-7). Границы — ADR-014 (IS1–IS9). Регулируется PSR_v1 R10.1 Блок 1 note (Nuclei/Ward CLI + 3 скила) + Блок 3 (ZAP MCP). Установлено 21.05.2026 на ветке `worktree-a8-infosec-tooling`; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
### 13.3. Скоуп
|
||||
|
||||
| Тип задачи | Кто отвечает |
|
||||
@@ -990,15 +982,11 @@ git fetch origin && git log HEAD..origin/main --oneline
|
||||
|
||||
Все 5 — механические, 0 LLM-вызовов в hot path.
|
||||
|
||||
### 16.4. Поведенческое правило «не использован ≠ проблема» (условное)
|
||||
### 16.4. Поведенческое правило «не использован ≠ проблема»
|
||||
|
||||
Узел «мозга», не задействованный в реальной работе, **не** считается проблемой и **не** подлежит автоматической пометке **при условии, что профильной задачи для него в эпизодах не было**. Это — capability-readiness, осознанная стратегия заказчика.
|
||||
Узел «мозга», не задействованный на реальной задаче, **не** считается проблемой и **не** подлежит автоматической пометке. Это — capability-readiness, осознанная стратегия заказчика. См. `memory/feedback_brain_unused_tools_not_problem.md`.
|
||||
|
||||
**Симметричное правило (missed activation):** если в эпизодах присутствует **хотя бы один** эпизод с `primary_rationale.task_classification`, соответствующим набору рекомендуемых узлов из `tools/observer-classification-map.json`, при этом `primary_rationale.node_chosen === 'direct'` и среди рекомендуемых узлов есть хотя бы один non-dormant (по `tools/.node-dormancy.json`, экстракт из [Tooling Прил.Н §3.5/§4.X](Tooling_v8_3.md) с двойным сигналом: `dormant: true` ИЛИ ключевое слово `DEFERRED` в колонке boundaries) — это **сигнал**, кандидат на разбор. Surface в STATUS.md (C5: `missed_activations: N`, ⚠️ при N>0) и в выводе `/brain-retro`. Не блок коммита, не auto-edit.
|
||||
|
||||
**Исключения:** DEFERRED-узлы (на момент v1.36 — #17 pg_partman, #44 Figma MCP, #50 Jupyter MCP, #54 n8n-mcp, #67 NightOwl) — для них «не активирован» = ожидаемое состояние, в missed activations не учитываются.
|
||||
|
||||
См. `memory/feedback_brain_unused_tools_not_problem.md`.
|
||||
**Исключение**: deprecated upstream-пакеты или физически сломанные инструменты (отдельная категория, `npm audit` / `composer outdated`).
|
||||
|
||||
### 16.5. Не override-floor §9
|
||||
|
||||
|
||||
+9
-133
File diff suppressed because one or more lines are too long
@@ -1,120 +0,0 @@
|
||||
# ADR-014: A8 infosec-tooling — наполнение раздела карты A8
|
||||
|
||||
**Status:** Accepted (amended 21.05.2026 — ZAP #68 + Ward #70 установлены портативно, статус PENDING INSTALL снят; см. Decision п.1/п.3 + Consequences)
|
||||
**Date:** 2026-05-21
|
||||
**Контекст:** эпик A8 infosec-tooling, spec `docs/superpowers/specs/2026-05-21-a8-infosec-tooling-design.md`, plan `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`, провенанс-вет `docs/security/infosec-vet.md`.
|
||||
|
||||
## Context
|
||||
|
||||
Раздел карты A8 «Информационная безопасность» формально существовал, но дедицированных
|
||||
узлов не имел — в него были лишь кросс-тегированы существующие фазовые инструменты
|
||||
(Semgrep #25, gitleaks #8). Портал Лидерра подходит к публичному запуску в интернете;
|
||||
заказчик попросил подобрать 5–7 плагинов (GitHub + Anthropic), закрывающих потребности
|
||||
безопасности портала.
|
||||
|
||||
Дефициты чистого A8 (технические инструменты защиты *работающего* портала — отдельно
|
||||
от процесса аудита D3, статики кода, БД-инструментов): динамическая «боевая» проверка
|
||||
(DAST) отсутствовала полностью; широкая проверка на известные уязвимости/экспозицию;
|
||||
Laravel-специфичная безопасность конфигурации; защита ПДн + соответствие 152-ФЗ;
|
||||
моделирование угроз под выход в интернет; единый go-live security-gate.
|
||||
|
||||
D3 (audit-security) уже покрывает Anthropic-арсенал (Security Guidance хук,
|
||||
`/security-review`, Trail of Bits скилы). DAST-движка и Laravel-сканера у Anthropic нет
|
||||
→ внешние GitHub-инструменты обоснованы. Для 152-ФЗ и угроз-под-наш-портал готового
|
||||
(знающего РФ-закон и устройство Лидерры) не существует → self-authored скилы.
|
||||
|
||||
**Решения заказчика (зафиксированы):** охват — мои инструменты + серверный слой (двумя
|
||||
слоями); ПДн/152-ФЗ — целиком; «боевая» DAST — да; подход — готовые движки + свои скилы
|
||||
для project-specific слотов.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **OWASP ZAP (#68)** — официальный ZAP «MCP Integration» add-on (`zaproxy/zap-extensions`,
|
||||
Apache-2.0). Глубокая DAST (spider + active scan): обход входа, инъекции, XSS.
|
||||
- **Постура:** on-demand, READ-only сканер, цель по умолчанию **локальная копия**
|
||||
(127.0.0.1), бой — только по явной команде (IS8). MCP-сервер в `.mcp.json`.
|
||||
- **Статус: УСТАНОВЛЕН 21.05.2026** (портативно, без choco) — ZAP cross-platform 2.17.0
|
||||
с MCP-аддоном `mcp-alpha-0.0.1` на portable Temurin JRE 17 (`bin/ZAP_2.17.0/`, gitignored);
|
||||
daemon API verified → 2.17.0. Add-on alpha. Доку: `docs/security/zap-setup.md`.
|
||||
2. **Nuclei (#69)** — `projectdiscovery/nuclei` v3.8.0 (MIT), Go-бинарь `bin/nuclei.exe`.
|
||||
Широкая проверка по YAML-шаблонам (известные CVE, экспозиция, TLS).
|
||||
- **Тип: CLI-инструмент, НЕ MCP-сервер.** Nuclei не говорит на протоколе MCP;
|
||||
обёртка в MCP-сервер = доп. attack surface. Интегрирован как CLI (как gitleaks #8 /
|
||||
squawk #15 / Trivy #26), вызывается по требованию скилом #73. Поэтому `.mcp.json`-блок
|
||||
и l1-watcher alias для #69 **не нужны**.
|
||||
- **Статус: УСТАНОВЛЕН + verified** (13 060 шаблонов; smoke: 1057 запросов к живому
|
||||
порталу, скан завершён). Квирки: цель `127.0.0.1` (не `localhost` — резолвер),
|
||||
`-rate-limit 20 -c 5` для однопоточного dev-сервера. Доку: `docs/security/nuclei-setup.md`.
|
||||
3. **Ward (#70)** — `Eljakani/ward` (MIT, Go CLI). Сканер misconfig/secrets Laravel:
|
||||
.env (8 проверок) + config/*.php (13) + deps (OSV.dev) + код (7 категорий).
|
||||
- **ЗАМЕНИЛ Enlightn** (исходный план): Enlightn оказался abandoned (Packagist) +
|
||||
официально без поддержки Laravel 13 (PR L12 висит 3+ мес). Ward — Go-бинарь, **не
|
||||
зависит от версии Laravel** → проблема снята. Заказчик выбрал «подобрать замену».
|
||||
Обоснование — `docs/security/infosec-vet.md` §ПЕРЕСМОТР #70. Pin по commit SHA (релизов нет).
|
||||
- **Тип: CLI-инструмент** (как Nuclei), не MCP, не Composer dev-dep.
|
||||
- **Статус: УСТАНОВЛЕН 21.05.2026** (портативно, без choco) — собран из исходника через
|
||||
portable Go 1.26.3 (`go install github.com/eljakani/ward@v0.4.1`) → `bin/ward.exe` v0.4.1;
|
||||
smoke `app/` → 2 находки (High APP_DEBUG, Medium APP_ENV). Доку: `docs/security/ward-setup.md`.
|
||||
- Caveat: молодой (фев 2026), single-maintainer → bus-factor; митигация — версия-pin + MIT-форк.
|
||||
4. **pdn-152fz-audit (#71)** — self-authored project-скил. Аудит ПДн + соответствие 152-ФЗ
|
||||
(2 режима: техника + закон), заземлён в `db/schema.sql`. Активен.
|
||||
5. **threat-model (#72)** — self-authored project-скил. STRIDE под наш портал, going-public,
|
||||
заземлён в `app/routes/`. Активен.
|
||||
6. **security-go-live (#73)** — self-authored project-скил, оркестратор go-live security-gate:
|
||||
#68–#72 + Semgrep #25 / Trivy #26 / gitleaks #8 / Trail of Bits #39 → вердикт GO/NO-GO. Активен.
|
||||
|
||||
**Серверный слой защиты** (WAF, anti-brute-force/rate-limit, DDoS, intrusion monitoring,
|
||||
secrets vault, TLS/HSTS/CSP, бэкапы + IR-runbook) — **out of scope** этого эпика (не плагины);
|
||||
фиксируется как открытые вопросы инфраструктуры (привязка к Б-1).
|
||||
|
||||
## Boundaries (конфликт-аудит)
|
||||
|
||||
- **IS1** ZAP #68 ↔ Semgrep #25: динамика (бьёт работающий портал) vs статика (читает код) — разные классы.
|
||||
- **IS2** Nuclei #69 ↔ ZAP #68: широта (известные дыры / экспозиция по шаблонам) vs глубина (логика приложения / активные инъекции) — комплементарны.
|
||||
- **IS3** Ward #70 ↔ Larastan #12 / Semgrep #25: misconfig/secrets/deps-сканер Laravel vs типы / generic-паттерны. Dep-скан Ward пересекается с Trivy #26 / Dependabot #27 — информационно, не гейт.
|
||||
- **IS4** pdn-152fz-audit #71 ↔ pg_anonymizer #29: аудит + направление (где ПДн, всё ли закрыто) vs инструмент маскирования.
|
||||
- **IS5** pdn-152fz-audit #71 ↔ D2 (право/юрист): техника + 152-ФЗ-чек-лист vs юридическое оформление документов.
|
||||
- **IS6** threat-model #72 ↔ Trail of Bits `audit-context-building` #39: наш портал + STRIDE + going-public vs generic deep code-audit.
|
||||
- **IS7** security-go-live #73 ↔ `audit-portal`: только безопасность + go-live-вердикт vs полный 14-фазный аудит; #73 *вызывает* D3, не заменяет.
|
||||
- **IS8** «боевая» проверка (#68/#69) на бою: гард — по умолчанию локальная/тестовая копия (127.0.0.1); бой только осознанно и аккуратно.
|
||||
- **IS9** провенанс-гейт: каждый внешний (ZAP/Nuclei/Ward) читается и проверяется на происхождение ДО установки (риск ≈13% ToxicSkills) — расширение процедуры `docs/audit/` attack-surface. Артефакт — `docs/security/infosec-vet.md`.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Enlightn (#70 исходный)** — отклонён: abandoned (Packagist), `composer.json` без Laravel 13, мейнтейнер не отвечает 3+ мес. Заменён Ward.
|
||||
- **Готовые маркетплейс-скилы threat-model / compliance** (fr33d3m0n, josemlopez, sickn33, и пр.) — отклонены для #71/#72: generic-методика (GDPR/SOC2, не 152-ФЗ; не знают устройство Лидерры) + риск ToxicSkills. Берутся как референс, не установка.
|
||||
- **Larafence** — отклонён: не выпущен (Q2 2026) + TALL/Livewire-стек (у нас Vue).
|
||||
- **Psalm + plugin-laravel taint-analysis** — не для слота #70: код-SAST (taint), пересекается с Semgrep #25 (IS3); не config-сканер.
|
||||
- **`laravel/agent-skills`** (официальный, чистый провенанс) — не security-сканер (общий Laravel-скил); опциональное доп. позже, не замена слота.
|
||||
- **Платные tiers** (Enlightn Pro, Snyk, ProjectDiscovery Cloud) — только OSS (РФ-резидентность, near-zero cost).
|
||||
- **Дедицированный dependency/SBOM-инструмент** — не добавляем: покрыто Dependabot #27 + Trivy #26 + ToB #39 + GitHub MCP (дубль §5 п.6).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- A8 непуст: 0 → 6 дедицированных узлов. **Все установлены (21.05.2026):** Nuclei #69 + Ward #70 (CLI в `bin/`) + ZAP #68 (portable JRE 17, daemon verified) + 3 скила #71/#72/#73.
|
||||
- Новая off-phase подкатегория `infosec-tooling` (17-я).
|
||||
- Провенанс-вет (IS9) каждого внешнего инструмента до установки — расширяет ADR-003-дисциплину; чужие security-скилы в чувствительные слоты (#71/#72) не тащим (ToxicSkills).
|
||||
- 152-ФЗ + угрозы-под-наш-портал сделаны своими скилами (РФ-/project-specific), а не generic-готовым.
|
||||
- DAST-движки таргетят локальную копию по умолчанию (IS8) — безопасно для боевого портала.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- ZAP #68 (alpha MCP + Java) и Ward #70 (Go) — **установлены портативно 21.05.2026** (без choco, по выбору заказчика «оба портативно»; setup-доки `docs/security/{zap,ward}-setup.md`). Footprint ~1.2 ГБ (Go SDK + JRE + ZAP) в `bin/*` gitignored. go-live-gate #73: шаг ZAP возвращает PENDING лишь при незапущенном ZAP-демоне (MCP-режим требует живого демона).
|
||||
- Ward — молодой single-maintainer проект (bus-factor); митигация SHA-pin + MIT-форкабельность.
|
||||
- Nuclei добавляет 126 МБ бинарь в `bin/` (gitignored, машинно-локальный) + 13k шаблонов.
|
||||
- ПДн-скил полагается на pg_anonymizer, который сам DEFERRED (OPEN-И-24, фаза 3) — чек-лист честно помечает «проверить вручную».
|
||||
|
||||
## Related Decisions
|
||||
|
||||
- **ADR-002** — tenant isolation via RLS; её правило драйвит ПДн-аудит (#71) и его технический режим.
|
||||
- **ADR-003** — D3 audit-security toolset; A8 — технический домен, граница: #73 *вызывает* D3-инструменты, не заменяет (IS7); провенанс-дисциплина IS9 наследует «defer непроверенного» из ADR-003.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/superpowers/specs/2026-05-21-a8-infosec-tooling-design.md` — design.
|
||||
- `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md` — plan.
|
||||
- `docs/security/infosec-vet.md` — IS9 провенанс-вет (вкл. §ПЕРЕСМОТР #70 Enlightn→Ward).
|
||||
- `docs/security/nuclei-setup.md` — установка/квирки Nuclei.
|
||||
- `docs/Открытые_вопросы_v8_3.md` — серверный слой (open questions).
|
||||
@@ -100,36 +100,3 @@ The observer episode is extended to `schema_version: 2` so a real factor analysi
|
||||
- Pravila §12 / §14 / §15 (hard-floor for router procedure step 1)
|
||||
- PSR_v1 R15 (off-phase routing extends to brain governance)
|
||||
- memory: `feedback_brain_unused_tools_not_problem.md`, `project_brain_governance_design.md`
|
||||
|
||||
## Amendment 2026-05-21: Conditional missed-activation rule (§16.4 v1.36)
|
||||
|
||||
The original §16.4 stated unconditionally that an unused node is not a problem. Real-world episodes show this is too permissive: when a profile-classified task (e.g. `refactor`) runs with `node_chosen === 'direct'` and a relevant non-dormant node exists in Tooling Прил.Н, the absence of activation IS a signal (router miss, not a problem in the node itself).
|
||||
|
||||
The rule now reads:
|
||||
|
||||
- **Unused + no profile task** → still not an alert (capability-readiness).
|
||||
- **Unused + profile task present** → "missed activation", surfaced in STATUS.md C5 and `/brain-retro`. Not a commit block.
|
||||
|
||||
**Mapping artefacts:**
|
||||
|
||||
- `tools/observer-classification-map.json` — manual mapping `classification → recommended_node_ids[]` (single source of truth). 10 classification buckets, populated from the real `tools/observer-transcript-parser.mjs` `classifyTask` dictionary (bugfix / cleanup / feature / memory-sync / monitoring / other / planning / question / refactor / analysis).
|
||||
- `tools/.node-dormancy.json` — generated from Прил.Н by `tools/extract-node-dormancy.mjs` (pre-commit job `extract-node-dormancy` in `lefthook.yml`). Uses a **two-signal** availability check: `dormant: true` in the 9-attribute row OR keyword `DEFERRED` in the boundaries column. Both signals normalize to the same JSON value, so consumers don't distinguish "permanent dormant" (#17) from "deferred-pending" (#44 / #50 / #54 / #67) — they're all "cannot activate right now".
|
||||
- `tools/missed-activations.mjs` — pure deterministic matcher. Exports `detectMissedActivations(episodes, classificationMap, dormancy)`. No fs, no exec.
|
||||
|
||||
**Detection threshold:** single episode (per user decision 2026-05-21). No smoothing; every qualifying episode counts.
|
||||
|
||||
**DEFERRED exclusion:** nodes flagged as unavailable in `.node-dormancy.json` are filtered before counting. Current dormant set: #1 (replaced), #17 (pg_partman, native-Windows), #44 (Figma MCP, no Figma account), #50 (Jupyter MCP, no Python ML env), #54 (n8n-mcp, n8n not in stack), #67 (NightOwl, pending Б-1 / Linux).
|
||||
|
||||
**Surfacing:**
|
||||
|
||||
- C5 `observer-coverage-checker` includes `missed.totalMissed` in its return value; the CLI emits `WARN — missed activations: N (see /brain-retro)` when N > 0.
|
||||
- `status-md-generator` renders `missed_activations: N` in the metrics block; C5 row turns ⚠️ when N > 0.
|
||||
- `/brain-retro` `analyze(episodes, { classificationMap, dormancy })` returns `missedActivations: { totalMissed, byNode, byClassification }` — the retro skill renders a per-node + per-classification breakdown.
|
||||
|
||||
**Initial measurement on May 2026 episodes:** 16 missed activations, dominated by memory-sync × 7 (CLAUDE.md edits without `#33 claude-md-management` chosen) and feature × 4 (no Superpowers brainstorming invocation). This is the kind of "router miss" signal the rule is designed to surface, not a problem in the unactivated nodes themselves.
|
||||
|
||||
**Linkage:**
|
||||
|
||||
- Pravila §16.4 v1.36 (2026-05-21).
|
||||
- Plan: `docs/superpowers/plans/2026-05-21-observer-missed-activations.md`.
|
||||
- Spec / decision rationale: this amendment.
|
||||
|
||||
@@ -21,11 +21,11 @@ function pos(ring, angleDeg) {
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (5) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.38', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.26', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.21', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.22', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.3', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
{ id: 'pravila', label: 'Pravila v1.35', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.22', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.19', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.19', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.2', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
@@ -95,14 +95,6 @@ const NODES = [
|
||||
{ id: 'php_insights', label: 'PHP Insights\n(dev-dep)', group: 'plugins', size: 18, ring: 2, ...pos(2, 220) },
|
||||
{ id: 'backend_patterns', label: 'backend-patterns\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 417) },
|
||||
{ id: 'nightowl', label: 'NightOwl\n(DEFERRED)', group: 'mcp', size: 16, ring: 3, ...pos(3, 427) },
|
||||
// A8 infosec-tooling (21.05.2026) — раздел «Информационная безопасность»
|
||||
{ id: 'mcp_zap', label: 'MCP: OWASP ZAP\n(DAST, pending install)', group: 'mcp', size: 18, ring: 5, ...pos(5, 360) },
|
||||
{ id: 'nuclei', label: 'Nuclei\n(CLI, известные уязвимости)', group: 'lefthook', size: 18, ring: 5, ...pos(5, 370) },
|
||||
{ id: 'ward', label: 'Ward\n(CLI, Laravel безопасность, pending)', group: 'lefthook', size: 18, ring: 5, ...pos(5, 380) },
|
||||
{ id: 'sk_pdn_152fz', label: 'ПДн / 152-ФЗ\n(скил)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 437) },
|
||||
{ id: 'sk_threat_model', label: 'Моделирование угроз\nSTRIDE (скил)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 447) },
|
||||
{ id: 'sk_security_golive', label: 'Прогон перед\nпубликацией (скил)', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 457) },
|
||||
|
||||
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
|
||||
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
|
||||
|
||||
@@ -430,25 +422,6 @@ const EDGES = [
|
||||
E('mcp_boost', 'backend_patterns', 'Eloquent-контекст'),
|
||||
E('nightowl', 'mcp_sentry', 'трейс ↔ ошибки\n(BT7, ADR-013)'),
|
||||
|
||||
// ── A8 INFOSEC-TOOLING (21.05.2026) — связи 6 новых узлов + L15 chain ──
|
||||
E('tooling', 'mcp_zap', '§4.X #A8 — реестр (DAST)'),
|
||||
E('tooling', 'nuclei', '§4.X #A8 — реестр (CVE CLI)'),
|
||||
E('tooling', 'ward', '§4.X #A8 — реестр (Laravel security)'),
|
||||
E('tooling', 'sk_pdn_152fz', '§4.X #A8 — реестр (ПДн скил)'),
|
||||
E('tooling', 'sk_threat_model', '§4.X #A8 — реестр (STRIDE скил)'),
|
||||
E('tooling', 'sk_security_golive', '§4.X #A8 — реестр (go-live скил)'),
|
||||
// sk_security_golive оркеструет — L15 security go-live chain
|
||||
E('sk_security_golive', 'mcp_zap', 'оркеструет (L15)'),
|
||||
E('sk_security_golive', 'nuclei', 'оркеструет (L15)'),
|
||||
E('sk_security_golive', 'ward', 'оркеструет (L15)'),
|
||||
E('sk_security_golive', 'sk_pdn_152fz', 'оркеструет (L15)'),
|
||||
E('sk_security_golive', 'sk_threat_model', 'оркеструет (L15)'),
|
||||
// L15 — reuse: существующие A8/D3 узлы
|
||||
E('sk_security_golive', 'mcp_semgrep', 'L15 go-live chain'),
|
||||
E('sk_security_golive', 'lh_gitleaks', 'L15 go-live chain'),
|
||||
E('sk_security_golive', 'tob_skills', 'L15 go-live chain'),
|
||||
E('sk_security_golive', 'sec_guidance', 'L15 go-live chain'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
@@ -553,7 +526,7 @@ const SECTIONS = [
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 147 узлов карты (141 base + 6 A8 infosec).
|
||||
// Узел -> раздел. Покрывает все 134 узла карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
@@ -618,9 +591,6 @@ const NODE_SECTION = {
|
||||
finance_plugin: 'C7', billing_audit: 'C6', ru_tax: 'C7',
|
||||
// A1 backend-tooling (20.05.2026) — раздел «Программирование — backend»
|
||||
rector: 'A1', php_insights: 'A1', backend_patterns: 'A1', nightowl: 'A1',
|
||||
// A8 infosec-tooling (21.05.2026) — раздел «Информационная безопасность»
|
||||
mcp_zap: 'A8', nuclei: 'A8', ward: 'A8',
|
||||
sk_pdn_152fz: 'A8', sk_threat_model: 'A8', sk_security_golive: 'A8',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user