diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 4c1a4f84..8aba4d11 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -49,6 +49,7 @@ cd frontend && npm install --legacy-peer-deps --no-audit --no-fund - Pre-push check: `git diff main..HEAD -- frontend/package.json frontend/package-lock.json` — если только один из двух тронут → STOP, regen lock. - Imports без deps entry (TypeScript авто-resolve через transitive) — **latent bomb** до first `npm ci`. - Reference incident: PR #344 (2026-05-17) добавил `lucide-react` без regen lockfile → deploy #135 fail → P0 hotfix PR #345 (commit `6ee20294f2`). +- **То же правило для `tradein-mvp/frontend/`** (#2770): там теперь тоже tracked `package-lock.json` + `npm ci` в Dockerfile и в `ci-tradein.yml`. До #2770 лока не было вовсе (лежал `pnpm-lock.yaml`, из которого никто не ставил), и состав зависимостей прод-образа определялся датой сборки. ## Prettier / lint diff --git a/.claude/rules/sql.md b/.claude/rules/sql.md index f221b4ef..136bc01a 100644 --- a/.claude/rules/sql.md +++ b/.claude/rules/sql.md @@ -16,11 +16,43 @@ paths: -- Контекст: что делает файл, зачем, порядок применения, dependencies. BEGIN; +SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout + -- DDL здесь (idempotent) COMMIT; ``` +## lock_timeout при блокирующем DDL (обязательно) + +Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) / +`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';` +сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml` +на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL). + +**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX` +на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической +psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за +ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только +ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный +отказ, повторить позже) вместо тихой очереди перед приложением. + +**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена +мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду, +поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит +очередь запросов приложения. + +**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует): +`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через +VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение, +оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально +в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и +обрежет `CONCURRENTLY` ниже по файлу. + +Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в +`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX +CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий. + ## Idempotency (обязательно) - `CREATE TABLE IF NOT EXISTS` diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 5b228267..522cba14 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -285,25 +285,42 @@ jobs: - name: Set up Node # Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine). - # npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет - # package-lock.json (Dockerfile ставит через npm install), а cache=npm - # без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint. + # cache: npm включён с #2770 — package-lock.json теперь tracked. uses: actions/setup-node@v4 with: node-version: "20" + cache: npm + cache-dependency-path: tradein-mvp/frontend/package-lock.json - - name: Install deps (npm install, no lockfile) - # ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage): - # --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches; - # --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`): - # в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но - # Dockerfile ставит именно npm install) → `npm ci` упал бы. - run: npm install --legacy-peer-deps --no-audit --no-fund + - name: Install deps (npm ci) + # ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage), чтобы гейт + # видел то же дерево, что уедет в образ. `ci`, а не `install` (#2770): до + # него лока не было вовсе (лежал мёртвый pnpm-lock.yaml, из которого никто + # не ставил), и версии в CI и в прод-образе выбирались независимо по дате + # сборки — гейт проверял не тот код, который деплоится. + # + # Правишь package.json — регенерируй лок в том же PR: `npm ci` требует + # точного match и иначе роняет и этот job, и build образа. + run: npm ci --legacy-peer-deps --no-audit --no-fund - name: Type-check (tsc --noEmit) # Blocking: любая TS-ошибка → job RED. run: npm run type-check + - name: Run tests (vitest) + # Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ + # проверки поведения: лэйн гейтил только типы и статический анализ, а оба + # молчат про то, что видит пользователь — пустое поле, погашенное число, + # отказ по частоте. Инфраструктура не изобретена, а взята у соседнего + # frontend/ (vitest + jsdom + testing-library), где сьют живёт давно. + # + # Пропусков в сьюте нет и быть не должно: сторож пропусков + # (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому + # пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722 + # запретил на бэкенде. Тест либо чинится, либо помечается `.fails` + # с причиной В КОДЕ. + run: npm test + - name: Lint (next lint) # Blocking: любая ESLint-ошибка → job RED. run: npm run lint diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 66d5fd7f..53e1d59b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -12,8 +12,18 @@ name: CI # единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через # connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango # установлен ниже), тогда как на macOS-dev они runtime-skip'аются. -# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см. -# .github/workflows/ci.yml как образец service-блока. +# +# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через +# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис- +# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ +# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод. +# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного +# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект +# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres +# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример +# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не +# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions) +# уронит сборку, если такая публикация всё же появится. on: # ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709). # WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым @@ -45,6 +55,25 @@ jobs: frontend: ${{ steps.filter.outputs.frontend }} steps: - uses: actions/checkout@v4 + + - name: "Guard: host-port collisions in workflows (#2757)" + # Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на + # КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает + # дефицитный слот раннера. Падение = merge заблокирован. + # python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3). + run: | + python3 scripts/check-workflow-ports.py --selftest + python3 scripts/check-workflow-ports.py + + - name: "Guard: блокирующий DDL без lock_timeout (#2752)" + # Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR, + # включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow — + # фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ + # каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину. + run: | + python3 scripts/check-migration-lock-timeout.py --selftest + python3 scripts/check-migration-lock-timeout.py + - uses: dorny/paths-filter@v3 id: filter with: diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 102dfde0..ee062b18 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -164,15 +164,25 @@ jobs: run: uv sync --frozen - name: Run pytest (tradein-mvp/backend) - # DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает - # ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state - # leak из другого test-модуля, pre-existing. Второй исторический deselect - # (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном - # прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан - # совпадать с backend-tests в ci-tradein.yml (pre-merge гейт). - run: | - uv run pytest -q \ - --deselect "tests/test_search_api.py::test_search_cache_hit" + # БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml. + # + # Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с + # объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит — + # global-state leak из другого модуля». Объяснение было неверным в обеих + # половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в + # /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на + # такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в + # pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё + # на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт. + # + # Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс + # дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо + # помечается xfail с причиной В КОДЕ, рядом с самим тестом. + # + # `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне — + # 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый + # пропуск роняет прогон через хук в tests/conftest.py. + run: uv run pytest -q -rs build-backend: runs-on: ubuntu-latest @@ -442,6 +452,35 @@ jobs: done echo "All migrations applied." + # (3b) Невалидные индексы после цикла (#2752). Оборванный + # CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false: + # планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка + # на записи всё равно платится. Молчит это так (воспроизведено на + # PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена + # применённой → следующий деплой прогоняет её заново → `CREATE INDEX + # CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает + # «relation already exists, skipping», выходит с кодом 0 → миграция + # помечается применённой, а индекс остаётся невалидным навсегда. + # Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она + # ловит и этот путь, и невалидные индексы любого другого + # происхождения (отменённый job, ручной CIC оператором). + # На 2026-08-07 на проде таких индексов 0 — это профилактика. + invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ + psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ + "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ + | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ + || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } + if [ -n "$invalid_idx" ]; then + echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" + echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" + echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" + echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." + echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" + echo " пересоздать индекс и повторить деплой." + exit 1 + fi + echo "✓ невалидных индексов нет." + # Bootstrap gendesign_reader password from env (post-migration, #976). # SQL migration 101_gendesign_reader_role.sql creates role passwordless; # password lives only in /opt/gendesign/tradein-mvp/.env.runtime. diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 7303d4cf..f267444a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -309,6 +309,31 @@ jobs: done echo "All migrations applied." + # Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX + # CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ + # пользуется, а re-run миграции его не чинит — `CREATE INDEX + # CONCURRENTLY IF NOT EXISTS` печатает «relation already exists, + # skipping» и выходит с кодом 0, после чего миграция помечается + # применённой, а индекс остаётся битым навсегда (воспроизведено на + # PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY. + # Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07 + # на проде таких индексов 0 — это профилактика. + invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ + | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ + || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } + if [ -n "$invalid_idx" ]; then + echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" + echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" + echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" + echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." + echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" + echo " пересоздать индекс и повторить деплой." + exit 1 + fi + echo "✓ невалидных индексов нет." + # Set tradein_fdw_reader password from env (post-migration bootstrap). # SQL migration 100_tradein_fdw_role.sql creates role passwordless; # password lives only in /opt/gendesign/backend/.env.runtime. diff --git a/backend/.env.example b/backend/.env.example index d17d9dd7..192a52d7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800 SCRAPE_KN_DEFAULT_REGIONS=66 # Путь к Playwright storage_state.json (commited в git, обновляется --save-state). SCRAPE_KN_STATE_PATH=data/playwright_state.json -# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен). -# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep. -# Переменная сохранена в core/deps.py для быстрого rollback. -SCRAPE_ADMIN_TOKEN= +# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437, +# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось +# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard +# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426). diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9a84b522..0b9ce491 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -146,9 +146,6 @@ class Settings(BaseSettings): # Path to a pre-captured Playwright storage_state.json (committed in repo, # used by worker to skip cold-start WAF challenge). scrape_kn_state_path: str = "data/playwright_state.json" - # Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls. - # Empty string = endpoint disabled. - scrape_admin_token: str = "" # ── #1945 KN-loader anti-ban (throttle + optional proxy) ────────────────── # DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py deleted file mode 100644 index 75fd3b06..00000000 --- a/backend/app/core/deps.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Shared FastAPI dependencies.""" - -from typing import Annotated - -from fastapi import Depends, Header, HTTPException, status - -from app.core.config import settings - - -def verify_admin_token( - x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, -) -> None: - """Verify admin token header. Raises 503 if not configured, 401 if invalid or missing.""" - if not settings.scrape_admin_token: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="admin disabled — set SCRAPE_ADMIN_TOKEN", - ) - if x_admin_token != settings.scrape_admin_token: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token") - - -AdminTokenAuth = Annotated[None, Depends(verify_admin_token)] diff --git a/backend/app/main.py b/backend/app/main.py index ee3969ec..e0ac46cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,7 +48,7 @@ from app.core import auth_db from app.core.audit_middleware import audit_log_middleware from app.core.auth import get_role from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event from app.services.auth_session import resolve_session_token logger = logging.getLogger(__name__) @@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers): # (middleware, маршруты) видели активный client с самого старта процесса. # GlitchTip не поддерживает profiling — profiles_sample_rate=0.0. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review): + # Starlette-интеграция кладёт request.data на transaction-scope так же, как + # на error-scope, поэтому голый scrub_sensitive_query (только URL) на + # before_send_transaction оставлял бы PII-канал открытым при любом + # glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring). sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -82,8 +87,14 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send=scrub_sensitive_query, - before_send_transaction=scrub_sensitive_query, + # Локальные переменные кадров стека НЕ уходят в мониторинг (#2753). + # Дефолт SDK — True: при любом исключении кадр несёт значения аргументов + # (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event + # сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не + # дополнительная мера, а условие, без которого скраб не полон. + include_local_variables=False, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ StarletteIntegration(), FastApiIntegration(), diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py index af936c31..66f9c2cc 100644 --- a/backend/app/observability/sentry_scrub.py +++ b/backend/app/observability/sentry_scrub.py @@ -1,17 +1,72 @@ -"""Хук before_send_transaction для GlitchTip/Sentry SDK. +"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK. -Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы -секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip -через HttpxIntegration performance-spans. +`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед +отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в +GlitchTip через HttpxIntegration performance-spans. + +`scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone / +client_email / phone / email / name / company / message) из events перед +отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на +sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в +request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в +`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/ +`message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный +вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет +сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/ +sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/ +client_email — Птица их не использует сегодня, но одинаковый механизм на +оба продукта проще сопровождать), плюс `company`/`message`, специфичные для +`PilotRequestInput` (#2457-review). + +`scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым +надо вешать ОБА канала — `before_send` И `before_send_transaction`. +Starlette-интеграция кладёт тело запроса в `request_info["data"]` на +transaction-scope точно так же, как на error-scope (scope-обработчики для +transactions НЕ пропускаются — пропуск бывает только на availability-чеках). +Если повесить PII-scrub только на `before_send`, а `before_send_transaction` +оставить на голом `scrub_sensitive_query` — PII продолжит течь через +transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review, +воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в +транзакцию с полным телом). """ from __future__ import annotations +import logging import re from typing import Any +from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.types import Event +logger = logging.getLogger(__name__) + +# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753). +# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе +# в новое событие, которое снова пойдёт через этот же обработчик; при +# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено: +# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика +# остаётся в stdout контейнера: текст трассировки значений переменных не несёт. +ignore_logger(__name__) + +_REDACTED = "[REDACTED]" +# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ +# (client_name/client_phone/client_email/phone/email/name, #396) + company/ +# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля +# свободного текста (#2457-review). +_PII_KEYS = frozenset( + { + "client_name", + "client_phone", + "client_email", + "phone", + "email", + "name", + "company", + "message", + } +) + _SENSITIVE_PARAM_RE = re.compile( r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)", re.IGNORECASE, @@ -47,3 +102,63 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None: request["url"] = _redact(request["url"]) return event + + +def _scrub(obj: Any) -> None: + """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" + if isinstance(obj, dict): + for key, value in obj.items(): + if isinstance(key, str) and key.lower() in _PII_KEYS: + obj[key] = _REDACTED + else: + _scrub(value) + elif isinstance(obj, list): + for item in obj: + _scrub(item) + + +def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: + """Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction) + перед отправкой в GlitchTip. + + Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list), + заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event + (не None) — иначе SDK дропнет отчёт целиком. + """ + if not isinstance(event, dict): + return event + request = event.get("request") + if isinstance(request, dict): + _scrub(request.get("data")) + _scrub(event.get("extra")) + _scrub(event.get("contexts")) + return event + + +def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None: + """Composed `before_send` / `before_send_transaction` handler: PII-scrub + + URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module + docstring (#2457-review): transaction-scope несёт `request.data` точно так + же, как error-scope. + + try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в + `capture_internal_exceptions`, который при исключении внутри хендлера + ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что + редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты + покрытия редактора: лучше отправить событие в состоянии "сколько успели + отредактировать до сбоя", чем не отправить вообще и молча остаться без + сигнала в мониторинге. + """ + try: + scrub_pii_event(event, hint) + scrub_sensitive_query(event, hint) + except Exception as exc: + # Ни трассировки, ни str(exc): и то и другое способно нести значения из + # ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что + # защищает (#2753). Имя класса исключения данных не несёт. Событием + # мониторинга эта строка не станет — см. ignore_logger выше. + logger.error( + "sentry_scrub.scrub_event: handler failed (%s), sending event as-is", + type(exc).__name__, + ) + return event diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index d85d24ca..6d6f139e 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index c71725af..6087e4a4 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str: ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 1268bfe6..bfba175d 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event logger = logging.getLogger(__name__) @@ -23,6 +23,11 @@ logger = logging.getLogger(__name__) # чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного # вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review, + # см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker + # вообще не скрабил error-события (тут before_send не было), а + # before_send_transaction был на голом scrub_sensitive_query (только URL) — + # оба канала пропускали PII. sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -30,7 +35,12 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send_transaction=scrub_sensitive_query, + # Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см. + # app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно. + # В воркере вектор шире: задачи держат в кадрах сырые ответы источников. + include_local_variables=False, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ CeleryIntegration(monitor_beat_tasks=True), SqlalchemyIntegration(), diff --git a/backend/tests/_sentry_wiring_probe.py b/backend/tests/_sentry_wiring_probe.py new file mode 100644 index 00000000..a2c72bf4 --- /dev/null +++ b/backend/tests/_sentry_wiring_probe.py @@ -0,0 +1,146 @@ +"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py. + +Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут +`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В +процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а +если бы и был достижим — глобальный клиент SDK остался бы живым для всех +последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает +вместе с ней. + +Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а +DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются +случайно на каждый запуск — кадр стека несёт не только переменные, но и строки +исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной. + +stdout — одна строка JSON: counts / scrub_handler_entries / markers / payloads +(тело каждого канала отдельно — см. `main`). +""" + +from __future__ import annotations + +import importlib +import io +import itertools +import json +import sys +import uuid +from typing import Any + +import sentry_sdk + +_FAILURE_CAP = 3 + + +def _fresh(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex}" + + +def _leaking_event(markers: dict[str, str]) -> dict[str, Any]: + """Событие с ПДн в трёх местах, которые закрывает scrub_event.""" + return { + "message": "sentry-wiring-probe", + "level": "error", + "request": { + "data": {"phone": markers["phone"], "message": markers["free_text"]}, + "url": f"https://example.invalid/probe?api_key={markers['url_secret']}", + }, + } + + +def main(module: str) -> int: + importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init() + + client = sentry_sdk.get_client() + sent: list[str] = [] + + def _record(envelope: Any) -> None: + buf = io.BytesIO() + envelope.serialize_into(buf) + sent.append(buf.getvalue().decode("utf-8", "replace")) + + client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign] + + markers = { + "phone": _fresh("probe-phone"), + "free_text": _fresh("probe-free-text"), + "url_secret": _fresh("probe-url-secret"), + "local_var": _fresh("probe-local-var"), + } + + # 1. Канал ошибок (before_send). + sentry_sdk.capture_event(_leaking_event(markers)) + after_error = len(sent) + + # 2. Канал транзакций (before_send_transaction) — Starlette кладёт + # request.data на transaction-scope так же, как на error-scope. + transaction = _leaking_event(markers) + transaction["type"] = "transaction" + transaction["transaction"] = "sentry-wiring-probe-tx" + transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}} + transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z" + transaction["timestamp"] = "2026-01-01T00:00:01.000000Z" + transaction["spans"] = [] + sentry_sdk.capture_event(transaction) + after_transaction = len(sent) + + # 3. Локальные переменные кадра стека (include_local_variables). Имя + # переменной произвольное — ключевой скраб такое не ловит по построению. + def _raise_with_local() -> None: + applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека + raise RuntimeError("sentry-wiring-probe boom") + + try: + _raise_with_local() + except RuntimeError: + sentry_sdk.capture_exception() + after_exception = len(sent) + + # 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка + # журнала об отказе уходит в мониторинг через LoggingIntegration, снова + # попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса + # проверено: не завершается, 1000+ вложенных трассировок за минуту). + # Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе + # проба на сломанном коде висела бы вместо того, чтобы честно покраснеть. + from app.observability import sentry_scrub + + original = sentry_scrub.scrub_pii_event + entries: list[int] = [] + + def _boom(*_a: Any, **_kw: Any) -> Any: + entries.append(1) + if len(entries) > _FAILURE_CAP: + return None + raise RuntimeError("sentry-wiring-probe scrubber failure") + + sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment] + try: + # Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is"). + sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"}) + finally: + sentry_scrub.scrub_pii_event = original # type: ignore[assignment] + after_scrub_failure = len(sent) + + # Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные + # переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит + # чужую проверку и мешает понять, что именно сломано. + bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure] + names = ["error", "transaction", "exception", "scrub_failure"] + spans = dict(zip(names, itertools.pairwise(bounds), strict=True)) + + print( + json.dumps( + { + "counts": {name: end - start for name, (start, end) in spans.items()}, + "scrub_handler_entries": len(entries), + "markers": markers, + "payloads": { + name: "\n".join(sent[start:end]) for name, (start, end) in spans.items() + }, + } + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/backend/tests/api/v1/test_admin_cadastre.py b/backend/tests/api/v1/test_admin_cadastre.py index 189ecba1..9d7fdb27 100644 --- a/backend/tests/api/v1/test_admin_cadastre.py +++ b/backend/tests/api/v1/test_admin_cadastre.py @@ -15,10 +15,6 @@ from fastapi.testclient import TestClient from app.main import app -# Токен для тестов (не реальный) -ADMIN_TOKEN = "test-admin-token" -ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} - def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None): """Создать mock db session с преднастроенными ответами.""" @@ -97,7 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]: # ── Tests ──────────────────────────────────────────────────────────────────── -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_pilot_returns_job_id() -> None: """POST /cadastre/jobs scope=pilot → job_id + targets_total.""" quarters_50 = [f"66:41:{i:07d}" for i in range(50)] @@ -114,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None: response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "pilot"}, - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text @@ -127,7 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list() -> None: """POST /cadastre/jobs scope=manual_list с явным списком.""" db = _make_mock_db() @@ -146,7 +139,6 @@ def test_create_job_manual_list() -> None: "scope": "manual_list", "quarters": ["66:41:0303161", "66:41:0303162"], }, - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text @@ -156,7 +148,6 @@ def test_create_job_manual_list() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list_empty_quarters_returns_400() -> None: """scope=manual_list без quarters → 400.""" db = _make_mock_db() @@ -170,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None: response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "manual_list"}, - headers=ADMIN_HEADERS, ) assert response.status_code == 400 finally: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_list_jobs_returns_list() -> None: """GET /cadastre/jobs → список jobs.""" db = _make_mock_db(job_row=_make_sample_job_row()) @@ -194,7 +183,7 @@ def test_list_jobs_returns_list() -> None: try: client = TestClient(app) - response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS) + response = client.get("/api/v1/admin/cadastre/jobs") assert response.status_code == 200, response.text body = response.json() assert isinstance(body, list) @@ -206,7 +195,6 @@ def test_list_jobs_returns_list() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_get_job_not_found_returns_404() -> None: """GET /cadastre/jobs/9999 → 404.""" db = MagicMock() @@ -220,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None: try: client = TestClient(app) - response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS) + response = client.get("/api/v1/admin/cadastre/jobs/9999") assert response.status_code == 404 finally: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_success() -> None: """POST /cadastre/jobs/42/cancel → {cancelled: true}.""" db = MagicMock() @@ -243,7 +230,6 @@ def test_cancel_job_success() -> None: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/42/cancel", - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text body = response.json() @@ -253,7 +239,6 @@ def test_cancel_job_success() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_not_found_returns_404() -> None: """POST /cadastre/jobs/9999/cancel когда job не найден → 404.""" db = MagicMock() @@ -270,7 +255,6 @@ def test_cancel_job_not_found_returns_404() -> None: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/9999/cancel", - headers=ADMIN_HEADERS, ) assert response.status_code == 404 finally: diff --git a/backend/tests/api/v1/test_admin_ekburg_permits.py b/backend/tests/api/v1/test_admin_ekburg_permits.py index 1c98bac3..45d9bd85 100644 --- a/backend/tests/api/v1/test_admin_ekburg_permits.py +++ b/backend/tests/api/v1/test_admin_ekburg_permits.py @@ -4,7 +4,11 @@ - валидный запрос без year → scope all_years_2022_2026, task_id в ответе - валидный запрос с year=2026 → scope year_2026 - year < 2022 или > 2030 → 422 -- отсутствие X-Admin-Token → 401/503 + +Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие +X-Admin-Token → 401/503» стояла в этом докстринге, но соответствующего теста в +файле нет. Гейт `/api/v1/admin/*` — middleware `rbac_guard` (app/main.py), +покрыт tests/test_rbac.py. """ from __future__ import annotations @@ -16,8 +20,6 @@ from fastapi.testclient import TestClient from app.main import app -ADMIN_TOKEN = "test-admin-token" -ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} ENDPOINT = "/api/v1/admin/scrape/ekburg-permits" @@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock: return result -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_refresh_all_returns_task_id() -> None: """POST без year → refresh_all queued, scope=all_years_2022_2026.""" mock_result = _mock_task("task-all-001") @@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None: ): mock_refresh_all.apply_async.return_value = mock_result client = TestClient(app) - response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={}) assert response.status_code == 200, response.text body = response.json() @@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None: assert "queued_at" in body -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_refresh_year_returns_task_id() -> None: """POST year=2026 → refresh_year queued, scope=year_2026.""" mock_result = _mock_task("task-year-002") @@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None: ): mock_refresh_year.apply_async.return_value = mock_result client = TestClient(app) - response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={"year": 2026}) assert response.status_code == 200, response.text body = response.json() @@ -67,9 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None: @pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999]) -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_invalid_year_returns_422(bad_year: int) -> None: """year вне диапазона [2022, 2030] → 422 Unprocessable Entity.""" client = TestClient(app) - response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={"year": bad_year}) assert response.status_code == 422, f"year={bad_year} должен возвращать 422" diff --git a/backend/tests/services/scrapers/test_ekb_krt_registry.py b/backend/tests/services/scrapers/test_ekb_krt_registry.py index 2463fc8b..9972e217 100644 --- a/backend/tests/services/scrapers/test_ekb_krt_registry.py +++ b/backend/tests/services/scrapers/test_ekb_krt_registry.py @@ -197,32 +197,74 @@ class TestParseBabushkinaBlock: assert site.krt_kind == "жилой застройки" -class TestMultiSiteCountFromDump: - """Проверка количества площадок из реального дампа страницы.""" +# ── Многоблочная страница: два года, три площадки, повторяющийся номер ──────── +# +# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший +# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у +# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а +# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому +# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ. +# +# Свойство, ради которого они писались, от дампа не зависит и выражается +# страницей из блоков, которые уже лежат в этом файле: +# • несколько

-секций с годами разбираются в одном проходе; +# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ, +# поэтому «2.» встречается дважды — и site_name всё равно обязан быть +# уникальным (номер сам по себе ключом не является); +# • блоки лежат внутри контейнера
— это ветка `_extract_krt_section` +# через `find_parent`, которой одиночные блоки выше не касаются вовсе. +# +# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778): +# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора +# реального документа, синтетической страницей оно не заменяется. На проде +# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но +# автоматической проверки этого больше нет — она вернётся, когда в репозитории +# появится образец страницы. +_MULTISITE_PAGE = ( + "
" + + _BRUSNIKA_BLOCK + + _BABUSHKINA_BLOCK + + textwrap.dedent( + """\ +

 2. КРТ нежилой застройки в районе ул. Краснофлотцев:

+

Площадь территории: 3,65 га;
\ +Градостроительный потенциал: 117 640 кв. м;
\ +Всего многоквартирных домов: 13.

+ """ + ) + + "
" +) + + +class TestMultiSitePage: + """Разбор страницы из нескольких блоков — без локального дампа.""" @pytest.fixture(scope="class") - def dump_html(self): - """Загружает локальный дамп страницы. + def sites(self): + return parse_krt_page(_MULTISITE_PAGE) - Тест пропускается если файл отсутствует (CI без дампа). - """ - import os + def test_all_three_sites_parsed(self, sites): + """Три блока в двух годовых секциях → три площадки.""" + assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}" - dump_path = os.path.join( - os.path.dirname(__file__), - "../../../../.playwright-mcp/ekb-docs/krt_page_raw.html", - ) - if not os.path.exists(dump_path): - pytest.skip("krt_page_raw.html dump not found — skip real-dump test") - with open(dump_path, encoding="utf-8") as fh: - return fh.read() - - def test_site_count_from_dump(self, dump_html): - """Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15).""" - sites = parse_krt_page(dump_html) - assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}" - - def test_no_duplicate_site_names(self, dump_html): - sites = parse_krt_page(dump_html) + def test_no_duplicate_site_names(self, sites): + """Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны.""" names = [s.site_name for s in sites] - assert len(names) == len(set(names)), "Дубликаты site_name в результате" + assert sum(n.startswith("2.") for n in names) == 2, names + assert len(names) == len(set(names)), f"Дубликаты site_name: {names}" + + def test_blocks_do_not_leak_into_each_other(self, sites): + """Границы блоков соблюдены: договор и застройщик остались у своей площадки. + + Регресс, который этим ловится: если block_end съезжает, «Брусника» и + договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему. + """ + with_developer = [s for s in sites if s.developer_name] + assert len(with_developer) == 1, [s.developer_name for s in sites] + assert "Брусника" in with_developer[0].developer_name + assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1 + + def test_kinds_are_read_per_block(self, sites): + """Третий блок — «нежилой застройки», не должен унаследовать вид соседа.""" + kinds = sorted(s.krt_kind for s in sites if s.krt_kind) + assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index f04c2322..c5cab6f1 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -10,8 +10,10 @@ DNS-fail повторяет timeout на каждый analyze. 3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две раздельные таблицы внутри модуля). - 4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start → - ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store). + 4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на + поток, все получают одно и то же значение, и шторм заканчивается сложившимся + кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно + (#1370, см. сам тест). 5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at → следующий вызов идёт по сети заново (а не из устаревшего кэша). @@ -23,6 +25,7 @@ from __future__ import annotations import os import threading +import time from collections.abc import Iterator from typing import Any from unittest.mock import MagicMock, patch @@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate: class TestConcurrencySafe: - def test_single_flight_cold_start_one_network_call(self) -> None: - """16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов.""" - # GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock. + def test_cold_start_storm_bounded_and_cache_converges(self) -> None: + """16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток, + все получают одно и то же значение, и после шторма кэш отвечает без сети. + + ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это + было требование, которого код НЕ выполняет и выполнять не собирается: + сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze + сериализуются на время httpx-вызова даже для разных координат), а рядом с + ним написано, что cold-start на один ключ «может породить несколько + параллельных запросов… приемлемо». Тест зеленел не потому, что защита + работает, а потому что при GIL первый поток обычно успевал сложить + результат раньше остальных. + + Замер 2026-08-07, 200 штормов подряд: при дефолтном + `sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые + ~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов, + было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда + потоки реально чередуются, больше одного вызова дали 197 штормов из 200, + и в 173 из них вызовов было все 16. То есть утверждение ложно почти + всегда, когда гонка вообще случается, — чинить надо было тест. + + Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ + стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние + запросы бывают только на cold-start одного ключа и они идемпотентны. + Понадобится — это отдельная задача с отдельным обоснованием, а не + побочный эффект правки теста. + + `time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти + промах кэша до первой записи. Так тест мерит худший случай той самой + уступки, а не везение планировщика. + """ start_barrier = threading.Barrier(16) get_call_count = 0 get_lock = threading.Lock() @@ -253,8 +284,7 @@ class TestConcurrencySafe: nonlocal get_call_count with get_lock: get_call_count += 1 - # Микро-задержка — окно для других потоков добраться до lock'а. - # Не делаем sleep большим, чтобы тест не висел. + time.sleep(0.05) # окно, в котором остальные потоки видят промах return _make_httpx_response(_make_forecast_response()) client_ctx = MagicMock() @@ -276,11 +306,27 @@ class TestConcurrencySafe: t.start() for t in threads: t.join() + storm_calls = get_call_count + # Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что + # поход в сеть был бы виден счётчиком, а не отказом коннекта. + after_storm = weather_cache.get_weather_cached(56.84, 60.59) assert len(results) == 16 - assert all(r is not None for r in results) - # Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов. - assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}" + assert results[0] is not None + assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения" + # Потолок — число участников: в сеть идут только промахнувшиеся, по разу + # каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная + # запись в кэш); меньше единицы невозможно, кэш был пуст. + assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках" + # Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз: + # следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен + # снятого single-flight — без этого уступка превращается в дыру. + assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)] + assert after_storm == results[0] + assert get_call_count == storm_calls, ( + f"после шторма кэш обязан отвечать без сети, а вызовов стало " + f"{get_call_count} против {storm_calls}" + ) # ────────────────────────────────────────────────────────────────────────────── diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index 6fda8d13..02ca21bc 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -73,14 +73,22 @@ tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped -# ── Образцы, которых нет в git ──────────────────────────────────────────────── -# ДОЛГ, а не норма: парсеры проверяются на реальных дампах, которые лежат только -# в untracked `.playwright-mcp/`. Пока файлы не в репозитории, эти 8 проверок не -# исполняются нигде. Правильная починка — закоммитить образцы в tests/fixtures/ -# (как сделано в tradein-mvp/backend/tests/fixtures/*.html), после чего строки -# отсюда убрать. -tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_no_duplicate_site_names -tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_site_count_from_dump +# ── Образец, которого нет в git ─────────────────────────────────────────────── +# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py:: +# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не +# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит +# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), — +# зато свойство, которое они проверяли (несколько блоков на странице, уникальные +# site_name), выразимо без дампа и теперь проверяется на многоблочной странице, +# собранной из блоков, уже лежащих в том же тест-файле. +# +# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF +# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком. +# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) — +# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить +# нет: официальный градостроительный документ, без персональных данных. +# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`, +# убрать эти шесть строк. tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py index 13316e56..5d3bd7fc 100644 --- a/backend/tests/test_admin_weight_profiles.py +++ b/backend/tests/test_admin_weight_profiles.py @@ -6,9 +6,15 @@ - GET /{id} → 200 / 404 - PUT /{id} → 200 / 404 - DELETE /{id} → 204 / 404 -- 401 при отсутствии X-Admin-Token - 422 при невалидных weights (неизвестная категория, вес вне диапазона) +Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при +отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в +файле нет — заголовок просто отправлялся во все запросы и никем не читался +(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware +`rbac_guard` (app/main.py, `_ADMIN_API_RE` → 403 для role != admin) и покрыт +tests/test_rbac.py. + Mock-based: get_db переопределяется через dependency override. """ @@ -24,9 +30,6 @@ from app.core.db import get_db from app.main import app from app.services.site_finder.weight_profiles import WeightProfile -_ADMIN_TOKEN = "test-admin-token" -_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN} - _NOW = datetime.now(UTC) @@ -50,9 +53,7 @@ def _make_profile( @pytest.fixture() -def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient: - """TestClient с переопределённым SCRAPE_ADMIN_TOKEN.""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) +def client() -> TestClient: return TestClient(app) @@ -76,7 +77,7 @@ def _clear_overrides(): # ── GET list ─────────────────────────────────────────────────────────────────── -def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + пустой список.""" mock = MagicMock() _override_db(mock) @@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: [], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-x"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json() == [] @@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat _clear_overrides() -def test_list_returns_profiles( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + список профилей.""" profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")] mock = MagicMock() @@ -108,10 +106,9 @@ def test_list_returns_profiles( "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: profiles, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 body = r.json() @@ -124,7 +121,7 @@ def test_list_returns_profiles( # ── POST create ──────────────────────────────────────────────────────────────── -def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """POST создаёт профиль, возвращает его со статусом 201.""" created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5}) mock = MagicMock() @@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk "app.api.v1.admin_weight_profiles.create_profile", lambda db, payload: created, ) - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", @@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk "weights": {"school": 2.0, "park": 1.5}, "is_default": False, }, - headers=_HEADERS, ) assert r.status_code == 201 body = r.json() @@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk _clear_overrides() -def test_create_validation_unknown_category(client_with_token: TestClient) -> None: +def test_create_validation_unknown_category(client: TestClient) -> None: """POST с неизвестной POI-категорией → 422 (Pydantic validation).""" - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES }, - headers=_HEADERS, ) assert r.status_code == 422 -def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None: +def test_create_validation_weight_out_of_bounds(client: TestClient) -> None: """POST с весом вне [-2, 3] → 422.""" - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"school": 99.0}, }, - headers=_HEADERS, ) assert r.status_code == 422 @@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) - # ── GET one ──────────────────────────────────────────────────────────────────── -def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id}?user_id= → 200.""" profile = _make_profile(7) mock = MagicMock() @@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: profile, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles/7", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["id"] == 7 @@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo _clear_overrides() -def test_get_profile_not_found( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id} несуществующего профиля → 404.""" mock = MagicMock() _override_db(mock) @@ -216,10 +207,9 @@ def test_get_profile_not_found( "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: None, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -229,7 +219,7 @@ def test_get_profile_not_found( # ── PUT update ───────────────────────────────────────────────────────────────── -def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} → 200 + обновлённый профиль.""" updated = _make_profile(3, profile_name="Обновлённый") mock = MagicMock() @@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: updated, ) - r = client_with_token.put( + r = client.put( "/api/v1/admin/site-finder/weight-profiles/3", params={"user_id": "user-1"}, json={"profile_name": "Обновлённый"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["profile_name"] == "Обновлённый" @@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke _clear_overrides() -def test_update_profile_not_found( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) @@ -262,11 +249,10 @@ def test_update_profile_not_found( "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: None, ) - r = client_with_token.put( + r = client.put( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, json={"profile_name": "X"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -276,7 +262,7 @@ def test_update_profile_not_found( # ── DELETE ───────────────────────────────────────────────────────────────────── -def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} → 204.""" mock = MagicMock() _override_db(mock) @@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: True, ) - r = client_with_token.delete( + r = client.delete( "/api/v1/admin/site-finder/weight-profiles/5", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 204 finally: _clear_overrides() -def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) @@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: False, ) - r = client_with_token.delete( + r = client.delete( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon def test_list_include_system_calls_with_system( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch + client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets.""" system_profile = _make_profile( @@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: [user_profile, system_profile], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1", "include_system": "true"}, - headers=_HEADERS, ) assert r.status_code == 200 body = r.json() @@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system( def test_list_without_include_system_does_not_call_with_system( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch + client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET без include_system → list_profiles (только пользовательские профили).""" user_profile = _make_profile(1, user_id="user-1") @@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: called_with_system.append(True) or [], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 assert len(r.json()) == 1 diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index 75538a31..19ff11d1 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -2,14 +2,27 @@ Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init() только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, -и что scrub_sensitive_query redact-ит api keys из URL spans. +что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event +redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/ +company/message) из request.data/extra/contexts (#2457-review), и — в конце +файла — что до транспорта не доезжают ни ПДн тела запроса, ни значения +локальных переменных кадра стека, ни второе событие о сбое самого скраба +(#2753, поведение через подставной транспорт вместо поиска подстроки). """ +import json import os +import pathlib +import subprocess +import sys +from functools import lru_cache from unittest.mock import patch +import pytest import sentry_sdk +_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1] + def test_sdk_imports_without_error() -> None: """Все интеграции импортируются без ModuleNotFoundError.""" @@ -156,3 +169,331 @@ def test_scrub_handles_missing_spans() -> None: event: dict = {"request": {"url": "https://example.com"}} result = scrub_sensitive_query(event, {}) assert result["request"]["url"] == "https://example.com" + + +# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ──────────── +# +# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий +# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data, +# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk +# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором. + + +def test_scrub_pii_redacts_request_data() -> None: + """scrub_pii_event заменяет client_name/phone/email в request.data.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "client_name": "Иван Иванов", + "phone": "+79991234567", + "email": "ivan@example.com", + "address": "Екатеринбург, ул. Ленина 1", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["client_name"] == "[REDACTED]" + assert data["phone"] == "[REDACTED]" + assert data["email"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["address"] == "Екатеринбург, ул. Ленина 1" + + +def test_scrub_pii_redacts_pilot_request_company_and_message() -> None: + """scrub_pii_event заменяет company/message — свободный текст + PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают + телефоны/имена/адреса, а не только фиксированные name/phone/email + (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "company": "ООО Ромашка", + "message": "Меня зовут Иван, звоните на +79991234567", + "source": "landing", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["company"] == "[REDACTED]" + assert data["message"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["source"] == "landing" + + +def test_scrub_pii_redacts_client_prefixed_keys() -> None: + """Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) — + Птица их сегодня не использует, но одинаковый механизм на оба продукта + проще сопровождать (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "client_name": "Иван", + "client_phone": "+79991234567", + "client_email": "ivan@example.com", + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["client_name"] == "[REDACTED]" + assert extra["client_phone"] == "[REDACTED]" + assert extra["client_email"] == "[REDACTED]" + + +def test_scrub_pii_redacts_extra() -> None: + """scrub_pii_event заменяет PII-ключи в extra, не трогая остальное.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "phone": "+79990000000", + "email": "x@y.ru", + "name": "Пётр", + "lead_id": 42, + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["phone"] == "[REDACTED]" + assert extra["email"] == "[REDACTED]" + assert extra["name"] == "[REDACTED]" + assert extra["lead_id"] == 42 + + +def test_scrub_pii_is_case_insensitive() -> None: + """Сверка PII-ключей case-insensitive.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}} + result = scrub_pii_event(event, {}) + assert result["extra"]["Client_Name"] == "[REDACTED]" + assert result["extra"]["PHONE"] == "[REDACTED]" + + +def test_scrub_pii_redacts_nested_contexts() -> None: + """scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}} + result = scrub_pii_event(event, {}) + assert result["contexts"]["lead"]["email"] == "[REDACTED]" + assert result["contexts"]["trace"]["op"] == "http" + + +def test_scrub_pii_leaves_non_pii_untouched() -> None: + """scrub_pii_event не трогает event без PII-ключей.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": {"data": {"region": "66", "area_sqm": 50}}, + "extra": {"job": "geocode"}, + "level": "error", + } + result = scrub_pii_event(event, {}) + assert result["request"]["data"] == {"region": "66", "area_sqm": 50} + assert result["extra"] == {"job": "geocode"} + assert result["level"] == "error" + + +def test_scrub_pii_handles_missing_sections() -> None: + """scrub_pii_event не падает на пустом event.""" + from app.observability.sentry_scrub import scrub_pii_event + + result = scrub_pii_event({}, {}) + assert result == {} + + +def test_scrub_pii_handles_none_and_non_dict_sections() -> None: + """scrub_pii_event не бросает исключений на None/не-dict секциях.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"} + result = scrub_pii_event(event, {}) + assert result is event + + +def test_scrub_pii_returns_event_not_none() -> None: + """before_send должен вернуть event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + result = scrub_pii_event(event, {}) + assert result is not None + assert result is event + + +# ── scrub_event (composed before_send / before_send_transaction handler) ─────── +# +# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА +# канала (before_send И before_send_transaction). До #2457-review composed-хук +# висел только на before_send, а before_send_transaction оставался на голом +# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data +# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь +# через transaction-канал при glitchtip_traces_sample_rate > 0. + + +def test_scrub_event_composes_pii_and_url_secret_scrub() -> None: + """scrub_event применяет PII-scrub (ключ-based) И URL query-string secret + redact (regex) оба разом, не заменяя друг друга — разные классы данных.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = { + "request": { + "data": {"client_name": "Иван"}, + "url": "https://example.com?api_key=supersecret", + } + } + result = scrub_event(event, {}) + assert result is not None + assert result["request"]["data"]["client_name"] == "[REDACTED]" + assert "[REDACTED]" in result["request"]["url"] + assert "supersecret" not in result["request"]["url"] + + +def test_scrub_event_returns_event_not_none() -> None: + """scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_pii_event_exception() -> None: + """try/except в scrub_event — предохранитель: sentry_sdk оборачивает + before_send в capture_internal_exceptions, который при исключении ТОЛЬКО + логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал). + Если scrub_pii_event падает — scrub_event обязан вернуть event, а не + пробросить исключение дальше (#2457-review).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_pii_event", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_sensitive_query_exception() -> None: + """То же самое для второго шага композиции (URL-secret redact).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_sensitive_query", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +# ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ───── +# +# До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в +# файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут +# многострочные комментарии, где те же подстроки встречаются, — достаточно +# удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не +# говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом +# before_send (локальные переменные кадра стека уходят мимо ключевого скраба). +# +# Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе +# (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него +# доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а +# `capture_envelope` подменён до первого события. + + +@lru_cache(maxsize=2) +def _probe(module: str) -> str: + """Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку.""" + env = { + **os.environ, + "TESTING": "1", + # Синтаксически валидный DSN на несуществующий хост: init отработает, + # сети не будет даже если транспорт когда-нибудь перестанут подменять. + "GLITCHTIP_DSN": "https://probe@localhost.invalid/1", + # Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/), + # и без этого `import app` уехал бы в editable-установку пакета — то есть + # проба мерила бы чужое дерево, а не то, что рядом с ней лежит. + "PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]), + } + proc = subprocess.run( + [sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module], + cwd=_BACKEND_ROOT, + env=env, + capture_output=True, + text=True, + timeout=300, + check=False, + ) + assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}" + return proc.stdout.strip().splitlines()[-1] + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_pii_never_reaches_transport(module: str) -> None: + """Оба канала (error И transaction) отдают транспорту событие без ПДн. + + Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ + `before_send_transaction` — комментарий с теми же словами не спасает. + """ + probe = json.loads(_probe(module)) + markers = probe["markers"] + + for channel in ("error", "transaction"): + payload = probe["payloads"][channel] + # Контроль «событие вообще доехало»: без него проверка была бы зелёной + # и на пробе, которая молча ничего не отправила. + assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало" + assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал" + + leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload] + assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}" + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_local_variables_never_reach_transport(module: str) -> None: + """`include_local_variables=False`: значения локальных переменных кадра стека + не уходят в мониторинг (#2753). + + Ключевой скраб такое не ловит по построению — имя переменной произвольно, + а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()` + (в sentry-sdk он по умолчанию `True`). + """ + probe = json.loads(_probe(module)) + payload = probe["payloads"]["exception"] + + assert probe["counts"]["exception"] == 1 + assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало" + + assert ( + probe["markers"]["local_var"] not in payload + ), f"{module}: значение локальной переменной ушло в мониторинг" + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_scrub_failure_does_not_spawn_second_event(module: str) -> None: + """Сбой самого скраба не порождает ВТОРОГО события (#2753). + + `logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об + отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR), + снова попала бы в скраб, снова упала — рекурсия, защиты от которой в SDK + нет (проверено на коде до фикса: не завершается). Красный, если + `ignore_logger` убрать: обработчик войдёт повторно. + """ + probe = json.loads(_probe(module)) + + assert ( + probe["scrub_handler_entries"] == 1 + ), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг" + assert probe["counts"]["scrub_failure"] == 1 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 768c2af9..9e04bd6b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,10 +17,66 @@ # # Postgres + Redis run alongside the app on the same VM (Discovery mode). # Volumes are shared with docker-compose.yml so switching between files preserves data. +# +# ── logging: journald (#2761) ──────────────────────────────────────────────── +# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ +# и живёт в /var/lib/docker/containers// (умирает вместе с контейнером). +# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН +# способ на обе половины, расхождение двух стеков дороже в поддержке. +# +# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера): +# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека +# postgres 2.2 ← 162 МБ за 74 дня +# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5 +# ИТОГО ~39 МБ/сутки +# Бюджет journald (замер там же, сообщение самого systemd-journald): +# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён +# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G). +# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки +# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины. +# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с +# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс +# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ. +# +# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть): +# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер +# # История через пересоздания: журнал принадлежит root:systemd-journal, а +# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и +# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт +# # "No entries". Рабочий однострочник — через docker-группу: +# docker run --rm -v /:/host:ro alpine chroot /host sh -c \ +# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"' +# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста +# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760). +# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID= +# # — так читается лог УЖЕ УДАЛЁННОГО контейнера). +# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl +# заработает напрямую (host-config, не этот файл). До этого правка не регрессия. +# +# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так +# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так +# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки: +# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка +# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без +# ночных прогонов. +# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при +# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker +# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это +# host drop-in journald.conf.d, не этот файл. +# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями. +# +# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной +# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры +# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size. +x-logging: &default-logging + driver: journald + options: + tag: "{{.Name}}" services: postgres: image: postgis/postgis:16-3.4 + logging: *default-logging restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB} @@ -46,6 +102,7 @@ services: redis: image: redis:7-alpine + logging: *default-logging restart: unless-stopped volumes: - redis_data:/data @@ -54,6 +111,40 @@ services: interval: 10s timeout: 3s retries: 5 + # #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ + # его достать. До этого redis жил только в gendesign_default, а tradein — в + # gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не + # резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а + # отсутствующая связность (см. #2709). + # + # Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml + # поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES — + # ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый + # сервис в tradein-compose в этот список не попадает и `--no-deps` его не + # подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал + # бы в пустоту. Правка того списка = правка deploy-tradein.yml, который + # сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком. + # + # aliases: тот же приём, что уже применён к postgres выше — стабильное имя + # gendesign-redis вместо compose-зависимого gendesign-redis-1. + # ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется + # блок networks:, неявная привязка к default пропадает, и backend/worker/ + # beat/glitchtip потеряли бы брокер (та же грабля описана у postgres). + # + # Разделение ключей — по НОМЕРУ БД, инстанс общий: + # db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей + # db1 — trade-in (SearchCache) ← вводится здесь + # db2 — glitchtip (см. REDIS_URL ниже) + # Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на + # брокере celery вытеснял бы поставленные в очередь таски. Значит tradein + # обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если + # tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая + # celery. Тогда — отдельный инстанс, а не смена политики вытеснения. + networks: + default: {} + shared: + aliases: + - gendesign-redis # OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI # вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze — @@ -76,6 +167,7 @@ services: # отдельно (docs/osrm-routing.md). osrm: image: osrm/osrm-backend:latest + logging: *default-logging restart: unless-stopped command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm volumes: @@ -103,6 +195,7 @@ services: # (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000. osrm-walk: image: osrm/osrm-backend:latest + logging: *default-logging restart: unless-stopped command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm volumes: @@ -118,6 +211,7 @@ services: backend: image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped # .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG). # required: false — compose не падает если файла нет (первый деплой). @@ -153,6 +247,7 @@ services: frontend: image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped ports: - "127.0.0.1:3000:3000" @@ -172,6 +267,7 @@ services: worker: # Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium. image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped env_file: - path: ./backend/.env @@ -201,6 +297,7 @@ services: beat: # Lean backend-образ (без Chromium) — beat только триггерит таски в Redis. image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped env_file: - path: ./backend/.env @@ -219,6 +316,7 @@ services: glitchtip-web: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-web + logging: *default-logging # profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`. # Bootstrap script activates the profile after DB + secrets are ready. # On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env. @@ -254,6 +352,7 @@ services: glitchtip-worker: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-worker + logging: *default-logging profiles: ["glitchtip"] depends_on: postgres: @@ -272,6 +371,7 @@ services: caddy: image: caddy:2 + logging: *default-logging restart: unless-stopped ports: - "80:80" @@ -310,6 +410,7 @@ services: # deploy.yml запускает: docker compose build glitchtip-auth-forwarder build: ./ops/glitchtip-auth-forwarder container_name: gendesign-auth-forwarder + logging: *default-logging restart: unless-stopped environment: GLITCHTIP_DSN: ${GLITCHTIP_DSN} diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md index 61180727..96dcfe6c 100644 --- a/docs/Secrets_Rotation_Policy.md +++ b/docs/Secrets_Rotation_Policy.md @@ -69,7 +69,7 @@ | `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) | | `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** | | `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** | -| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) | +| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — | ### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`) @@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`): --- -## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance) +## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance) -Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime». -**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436 -(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth. -Поле оставлено в `core/deps.py` только для быстрого rollback. +Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime». +**Ротировать нечего: переменной больше нет.** -**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока -`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F** -(процедура: `sed` в `backend/.env.runtime` → `up -d --force-recreate --no-deps backend beat`, -downtime отсутствует). Фактический прод-прогон ротации — операционное действие -(Anton), не выполняется в рамках этого PR. +История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран +со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и +dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до +#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему +репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте — +в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно +означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть +хранение поля не экономило ничего. Удалено вместе с `core/deps.py`, +`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах. + +Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**: +1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only` + для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста). +2. Caddy basic_auth на весь `gendsgn.ru` (PR #426). + +Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно: +`Settings.model_config` — `extra="ignore"`, лишняя переменная безвредна. `JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** — добавить в реестр (класс **F**) при внедрении JWT-аутентификации. diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx index bd7144c7..61e580a8 100644 --- a/frontend/src/app/legacy/site-finder/page.tsx +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; import { LandTab } from "@/components/site-finder/LandTab"; import { MarketTab } from "@/components/site-finder/MarketTab"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; -import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; import { useCustomPois } from "@/hooks/useCustomPois"; @@ -22,6 +22,7 @@ import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, } from "@/lib/api/weightProfiles"; +import { useMe } from "@/lib/useMe"; // SiteMap imports Leaflet which requires browser APIs — load without SSR const SiteMap = dynamic( @@ -129,16 +130,16 @@ function SiteFinderContent() { // Ref to skip the initial mount effect (we only re-analyze on actual changes). const weightsChangeInitializedRef = useRef(false); - const [profileUserId, setProfileUserId] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_user_id") ?? ""), - ); - const [adminToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); + // Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения + // из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`, + // который сервер не читает с #437) — оба недостижимы без DevTools. + // + // 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без + // `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом + // `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без + // user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль + // молча не применялся бы, а UI показывал бы его ползунки. + const profileUserId = useMe().data?.username ?? ""; // Lazy init: считаем initialTab один раз на mount (useState всё равно // игнорирует initializer после первого render — не тратим CPU). const [tab, setTabState] = useState(() => { @@ -192,12 +193,7 @@ function SiteFinderContent() { setIsochrones(undefined); mutate({ cad: currentData.cad_num, - options: - profileId != null - ? { profileId } - : currentProfileUserId - ? { profileUserId: currentProfileUserId, weights } - : { weights }, + options: buildAnalyzeOptions(weights, profileId, currentProfileUserId), }); // mutate is stable from useMutation — safe to omit from deps. // data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num @@ -216,12 +212,11 @@ function SiteFinderContent() { // slider values are always respected even without a saved profile (#201). mutate({ cad: cadNum, - options: - activeProfileId != null - ? { profileId: activeProfileId } - : profileUserId - ? { profileUserId, weights: currentWeights } - : { weights: currentWeights }, + options: buildAnalyzeOptions( + currentWeights, + activeProfileId, + profileUserId, + ), }); } @@ -329,46 +324,9 @@ function SiteFinderContent() { {/* Weight profile panel — collapsible, below header */}
- {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} - {!!adminToken && ( -
- - { - setProfileUserId(e.target.value); - if (typeof window !== "undefined") { - localStorage.setItem("admin_user_id", e.target.value); - } - }} - /> -
- )} {/* Recalculation indicator — shown while re-analyze is in-flight after weights change (data already loaded, pendingWeightsChange set). */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index e4c70c5f..31117249 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -10,8 +10,8 @@ /* ===================== SCOPED TOKENS (dark) ===================== */ .pticaRoot[data-theme="dark"] { --font-ui: - var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", - system-ui, sans-serif; + var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui, + sans-serif; --font-mono: var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace, monospace; @@ -907,31 +907,6 @@ letter-spacing: 0.08em; } -/* ===================== PLACEHOLDER PANEL ===================== */ -.placeholderPanel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - text-align: center; - min-height: 240px; - border-style: dashed; -} -.placeholderPanel .soon { - font-size: 22px; - letter-spacing: 0.12em; - color: var(--text-soft); - font-weight: 700; - text-transform: uppercase; -} -.placeholderPanel p { - font-size: 10px; - color: var(--text-soft); - margin: 0; - max-width: 280px; -} - /* ===================== STATE SCREENS ===================== */ .stateScreen { display: grid; diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 14448afb..8e9d02f9 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -526,14 +526,15 @@ function VariantPanel({ ? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " : ""} НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК - РФ); НДС начисляется только на паркинг (нежилые машиноместа). - Входной НДС по строительству уже учтён в себестоимости. Налог на - прибыль — 25% (с 2025 года). Цена продажи жилья —{" "} + РФ); НДС начисляется на нежилое — паркинг (машиноместа) и + коммерцию 1-го этажа. Входной НДС по строительству уже учтён в + себестоимости. Налог на прибыль — 25% (с 2025 года). Цена продажи + жилья —{" "} {financial.price_is_calibrated ? `калибрована по рынку (${priceSourceCaption(financial)})` : "норматив класса (нет рыночных данных по участку)"} - ; себестоимость СМР и цена паркинга — нормативные ориентиры. - Коммерческие и офисные площади не учитываются. + ; себестоимость СМР, цена паркинга и цена нежилого — нормативные + ориентиры. {financial.financing_enabled && financial.financing_is_simplified ? " Финансирование упрощено: весь кассовый разрыв покрыт " + "кредитом по ставке-нормативу, проценты капитализируются, " + @@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { value={formatMoneyCompact(financial.gross_margin_rub)} /> , profileId: number | null, ) => void; - /** - * If provided, enables save/load from DB. - * Must be non-empty for CRUD functionality. - */ - userId?: string; - /** Admin token for CRUD API calls. */ - adminToken?: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -66,12 +60,7 @@ function weightsEqual( // ── Component ───────────────────────────────────────────────────────────────── -export function WeightProfilePanel({ - currentWeights, - onWeightsChange, - userId, - adminToken, -}: Props) { +export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { const [open, setOpen] = useState(false); // Local draft weights — editable before "Применить" @@ -89,11 +78,19 @@ export function WeightProfilePanel({ const [saveName, setSaveName] = useState(""); const [saveDefault, setSaveDefault] = useState(false); - // Profiles query (only when userId + adminToken provided) - const canUseCrud = !!userId && !!adminToken; - const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? ""); + // Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили + // руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было + // взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в + // #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут + // RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется). + // В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто + // бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен. + const { data: me } = useMe(); + const userId = me?.username ?? ""; + const canUseCrud = !!userId; + const profilesQuery = useWeightProfiles(userId); - const createMutation = useCreateProfile(adminToken ?? ""); + const createMutation = useCreateProfile(); // ── Handlers ──────────────────────────────────────────────────────────────── @@ -117,7 +114,7 @@ export function WeightProfilePanel({ } const handleSaveProfile = useCallback(async () => { - if (!canUseCrud || !userId || !adminToken) { + if (!canUseCrud || !userId) { setShowSaveDialog(false); return; } @@ -136,15 +133,7 @@ export function WeightProfilePanel({ } catch { // Error visible through createMutation.error } - }, [ - canUseCrud, - userId, - adminToken, - saveName, - draft, - saveDefault, - createMutation, - ]); + }, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]); // ── Derived ───────────────────────────────────────────────────────────────── @@ -242,6 +231,7 @@ export function WeightProfilePanel({
@@ -278,10 +268,13 @@ export function WeightProfilePanel({
)} - {/* Hint when no crud */} + {/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт + Caddy basic_auth) — остаётся для dev-запуска без прокси, где + /api/v1/me отвечает 401. Текст называет причину, а не действие: + вводить тут больше нечего. */} {!canUseCrud && (

- Укажите User ID и Admin Token для сохранения профилей. + Пользователь не определён — сохранение профилей недоступно.

)} @@ -362,6 +355,9 @@ export function WeightProfilePanel({ }} > Новый профиль +

+ Сохранится для пользователя {userId} — другие его не увидят. +

(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +const PROFILE = { + id: 1, + user_id: "admin", + profile_name: "Мой профиль", + weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 }, + is_default: true, + description: null, + created_at: "2026-05-15T05:30:51Z", + updated_at: "2026-05-15T05:30:51Z", +}; + +function renderPanel() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {}} + /> + , + ); +} + +/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */ +async function openPanel() { + const { default: userEvent } = await import("@testing-library/user-event"); + await userEvent.setup().click(screen.getByText("POI Веса")); +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([PROFILE]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => { + it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => { + renderPanel(); + await openPanel(); + + // Профили грузятся по user_id из /me, без ручного ввода и без токена. + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.getByText("Профиль:")).toBeTruthy(); + }); + + it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.queryByText(/Admin Token/i)).toBeNull(); + expect(screen.queryByText(/Укажите User ID/i)).toBeNull(); + }); + + it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => { + const crudCall = fetchMock.mock.calls.find(([input]) => + String(input).includes("/weight-profiles"), + ); + expect(crudCall).toBeTruthy(); + const headers = new Headers( + (crudCall?.[1] as RequestInit | undefined)?.headers, + ); + expect(headers.has("X-Admin-Token")).toBe(false); + }); + }); +}); + +describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => { + const weights = { ...POI_DEFAULT_WEIGHTS }; + + it("выбранный профиль уходит вместе с profileUserId", () => { + expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({ + profileId: 7, + profileUserId: "admin", + }); + }); + + it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => { + const options = buildAnalyzeOptions(weights, 7, ""); + expect(options.profileId).toBeUndefined(); + expect(options.weights).toEqual(weights); + }); + + it("без профиля уходят inline-веса и владелец для default-профиля", () => { + expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({ + profileUserId: "admin", + weights, + }); + }); +}); diff --git a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx deleted file mode 100644 index b72e33c9..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; - -interface AnalysisBreadcrumbProps { - cadNum: string; -} - -export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) { - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx deleted file mode 100644 index 487f5cd6..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import { ExternalLink } from "lucide-react"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface SubSection { - id: string; - label: string; -} - -interface NavSection { - id: string; - label: string; - sub?: SubSection[]; -} - -// ── Config ──────────────────────────────────────────────────────────────────── - -const NAV_SECTIONS: NavSection[] = [ - { id: "section-1", label: "1. Объект" }, - { id: "section-2", label: "2. Земля и риски" }, - { - id: "section-3", - label: "3. Рынок", - sub: [ - { id: "section-3-1", label: "3.1 Настройки выборки" }, - { id: "section-3-2", label: "3.2 Планировки" }, - { id: "section-3-3", label: "3.3 Остатки и скорость" }, - ], - }, - { id: "section-4", label: "4. Оценка" }, - { id: "section-5", label: "5. Атмосфера" }, - { - id: "section-6", - label: "6. Прогноз", - sub: [ - { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, - { id: "section-6-2", label: "6.2 Сценарии" }, - { id: "section-6-3", label: "6.3 Уверенность" }, - { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, - { id: "section-6-5", label: "6.5 Прозрачность скоринга" }, - { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, - ], - }, -]; - -// All section IDs in scroll order (for IntersectionObserver) -const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) => - s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id], -); - -// ── Component ───────────────────────────────────────────────────────────────── - -export function AnalysisSidebar() { - const [activeId, setActiveId] = useState(ALL_SECTION_IDS[0]); - const observerRef = useRef(null); - - // Scrollspy via IntersectionObserver - useEffect(() => { - const candidates = ALL_SECTION_IDS.map((id) => - document.getElementById(id), - ).filter((el): el is HTMLElement => el !== null); - - if (candidates.length === 0) return; - - // Track which sections are visible; pick topmost visible one - const visible = new Set(); - - observerRef.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - visible.add(entry.target.id); - } else { - visible.delete(entry.target.id); - } - }); - - // Pick the topmost section that is currently visible - const next = ALL_SECTION_IDS.find((id) => visible.has(id)); - if (next) setActiveId(next); - }, - { - root: null, - // Trigger when section top enters top 60% of viewport - rootMargin: "-8px 0px -40% 0px", - threshold: 0, - }, - ); - - candidates.forEach((el) => observerRef.current!.observe(el)); - - return () => { - observerRef.current?.disconnect(); - }; - }, []); - - function handleAnchorClick( - e: React.MouseEvent, - targetId: string, - ) { - e.preventDefault(); - const el = document.getElementById(targetId); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - setActiveId(targetId); - } - - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx deleted file mode 100644 index 272c6239..00000000 --- a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx +++ /dev/null @@ -1,410 +0,0 @@ -"use client"; - -/** - * MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b, - * epic #1953). - * - * Driven by the interactive 3D MassingScene: every time the user drags the - * этажность / секций sliders, Section7Concept maps the scene's `computeModel` - * result + the analysis context into a `MassingProgram` and hands it here via - * `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and - * render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость / - * прибыль / ROI). - * - * Robustness: - * • debounce — slider drags fire many programs; only the settled one is sent. - * • latest-wins — an in-flight request is superseded by a newer one via a - * monotonic request id; a stale response is dropped, never overwriting a - * fresher result (mutateAsync + id guard, no UI flicker from out-of-order). - * • last-good — on a failed recompute we keep the last successful values and - * show a subtle inline note rather than blanking the panel. - * • skeleton — a plain grey fade KPI grid while the FIRST recompute is in - * flight (no shimmer, per ui-conventions). - * - * Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but - * this strip uses the light KPI tokens via the shared KpiCard. - */ - -import { useEffect, useRef, useState } from "react"; -import { AlertTriangle } from "lucide-react"; - -import { KpiCard } from "@/components/analytics/KpiCard"; -import { Section } from "@/components/analytics/Section"; -import { - priceSourceCaption, - useRecomputeMassing, - type FinancialModel, - type MassingProgram, - type MassingRecomputeOutput, - type Teap, -} from "@/lib/concept-api"; - -const DEBOUNCE_MS = 250; - -// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ───────── - -const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); - -/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */ -function formatMoneyCompact(rub: number): string { - const abs = Math.abs(rub); - if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`; - if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`; - return `${nf.format(Math.round(rub))} ₽`; -} - -function formatInt(n: number): string { - return nf.format(Math.round(n)); -} - -function formatPct(fraction: number): string { - return `${(fraction * 100).toFixed(1)}%`; -} - -function formatFar(far: number): string { - return far.toLocaleString("ru-RU", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); -} - -// ── KPI grid ─────────────────────────────────────────────────────────────────── - -interface KpiGridProps { - teap: Teap; - financial: FinancialModel; - /** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */ - farTarget: number; - /** True → факт-КСИТ превышает регламентный потолок (model.over). */ - ksitOver: boolean; - /** Dim the strip while a fresher recompute is in flight (last-good values). */ - stale: boolean; -} - -function KpiGrid({ - teap, - financial, - farTarget, - ksitOver, - stale, -}: KpiGridProps) { - const netPositive = - financial.net_profit_rub > 0 - ? true - : financial.net_profit_rub < 0 - ? false - : null; - - return ( -
- {/* ТЭП */} -
- - - - -
- - {/* Финмодель */} -
- - - - - 0 - ? true - : financial.npv_rub < 0 - ? false - : null, - }} - hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct( - financial.discount_rate_used, - )} годовых).`} - /> - financial.discount_rate_used - ? true - : false, - }} - hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)." - /> - -
-
- ); -} - -// ── Skeleton (grey fade, no shimmer — ui-conventions) ────────────────────────── - -function SkeletonGrid() { - const cells = Array.from({ length: 7 }); - return ( -