merge(mera/b2c-antiabuse): влить main — платный флоу, скрубберы Sentry, ревайвл мёртвых оценок
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m31s

Ветка отстала на ~395 коммитов; за это время в main приехали B2C-этапы 1 и 4,
платный флоу, бесплатная проба покрытия и скрубберы Sentry для платежей (#2794).

Конфликты (везде — совмещение обеих сторон, не выбор):
- tradein-mvp/backend/app/api/v1/trade_in.py: _estimate_limiter/
  _resolve_quota_identity (анти-абуз анонимов, этот PR) сохранены рядом с
  ESTIMATE_READABLE_SQL/estimate_readable (retention, PR-D1, main); оба
  комментария перед вызовом estimate_quality() объединены.
- tradein-mvp/backend/tests/conftest.py: обе autouse-фикстуры
  (_reset_estimate_rate_limiter из этого PR и _no_leaked_password_verify_slots
  из main) сохранены, докстринг и импорты (sys, Path) объединены.

Полный прогон pytest после слияния: 4609 passed, 1 skipped (allowlisted —
WeasyPrint native libs), 0 failed. ruff check app tests — чисто.
This commit is contained in:
bot-backend 2026-08-15 23:10:43 +03:00
commit ddcd526284
581 changed files with 93616 additions and 13624 deletions

View file

@ -25,7 +25,8 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod
## Path triggers (Forgejo Actions, `.forgejo/workflows/`)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `ops/db-bootstrap/**`, `ops/docker-prune.sh`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- ⚠️ `ops/**` целиком **не** триггерит — только перечисленные подпути. Любой новый файл в `ops/`, который исполняется на VM (cron / шаг деплоя), надо добавлять в `paths:` явно, иначе он не доедет до `/opt/gendesign` и будет молча исполняться в старой версии
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md``.forgejo/workflows/deploy-obsidian.yml`
- `docs/**` alone → НЕ триггерит деплой

View file

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

View file

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

View file

@ -30,6 +30,7 @@ jobs:
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
browser: ${{ steps.filter.outputs.browser }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@ -43,25 +44,126 @@ jobs:
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
- 'tradein-mvp/uv.lock'
- 'tradein-mvp/pyproject.toml'
# auth/roles.yaml — общий RBAC-конфиг обоих стеков, лежит В КОРНЕ
# репы и монтируется в tradein-backend (/app/auth/roles.yaml).
# tests/test_rbac.py читает именно его, поэтому правка ролей обязана
# гонять и этот гейт. Без строки правка roles.yaml не запускала НИ
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
- 'auth/**'
- '.forgejo/workflows/ci-tradein.yml'
frontend:
- 'tradein-mvp/frontend/**'
- '.forgejo/workflows/ci-tradein.yml'
browser:
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
# members = backend + packages/*), со своим Dockerfile и без pyproject,
# поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе.
- 'tradein-mvp/browser/**'
- '.forgejo/workflows/ci-tradein.yml'
backend-tests:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true'
# Postgres-сервис (#2745). ДО него лэйн был mock-only: DATABASE_URL указывал на
# заведомо мёртвый `localhost:5432/test`, и девять тестов с `_live_session()`
# self-skip'ались — в CI они не бежали НИ РАЗУ. Так и разъехался со схемой
# test_house_dedup_merge (#2740: houses.url стал NOT NULL), а
# test_gar_flats_loader вообще падал до первого утверждения (#2744).
#
# Замер перед включением: полный сьют на mock-лэйне 122с / 3858 passed / 10 skipped,
# тот же сьют против живой БД — 106с / 3867 passed / 1 skipped. Живая БД не
# медленнее, поэтому НЕ добавляем второй job, а чиним этот: один прогон, на
# девять реальных проверок больше. Накладные — только подъём контейнера и
# bootstrap схемы (219 файлов, ~20с).
defaults:
run:
working-directory: ./tradein-mvp/backend
env:
# psycopg v3 требует parseable URL на импорте; реального коннекта нет —
# DB-тесты мокаются (mirror deploy-tradein.yml test-job).
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
# Имя контейнера уникально на прогон: параллельные PR не дерутся за него.
CI_PG: ci-pg-tradein-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- name: Поднять Postgres и собрать схему tradein
working-directory: .
# ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА.
# Раннер запускает и job, и сервис-контейнеры с `--network host` (видно в
# логе прогона: `docker create image=... network="host"`), а на 5432 того
# же хоста слушает ПРОДОВЫЙ Postgres. Попытка через `services:` +
# `ports: 5432:5432` кончилась тем, что сервис-контейнер не смог занять
# порт, а psql из job'а ушёл В ПРОД и получил
# `password authentication failed for user "tradein"`. То есть
# `localhost:5432` из job'а на этом раннере — боевая база, а не тестовая.
# Поэтому контейнер поднимаем сами, в bridge-сети, БЕЗ публикации порта,
# и ходим по его собственному IP: прод недостижим в принципе, параллельные
# прогоны не конфликтуют, psql берём из самого контейнера.
#
# ОДИН шаг, а не два: между шагами контейнер успевал исчезнуть, и
# bootstrap падал на `container is not running`.
#
# `pg_isready -h 127.0.0.1`, а НЕ через unix-сокет: на время initdb образ
# поднимает ВРЕМЕННЫЙ сервер с listen_addresses='' — по сокету он уже
# отвечает «accepting connections», хотя снаружи БД ещё не существует, а
# впереди рестарт. Проба по TCP зеленеет только на настоящем сервере —
# том самом, к которому пойдут тесты.
#
# postgis, не plain postgres: tests/tasks/test_cadastral_geo_match.py
# проверяет KNN по geometry (PostGIS_Version() в connectivity-probe).
# Имя БД ОБЯЗАНО отличаться от `test`: `_live_session()` считает DSN с
# `localhost:5432/test` заглушкой и вернул бы None — контейнер поднялся
# бы, а тесты всё равно скипались.
run: |
set -u
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
postgis/postgis:16-3.4
ready=""
for _ in $(seq 1 45); do
if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U tradein -q 2>/dev/null; then
ready=1; break
fi
[ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break
sleep 2
done
if [ -z "$ready" ]; then
echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)"
docker logs --tail 50 "$CI_PG" 2>&1 || true
exit 1
fi
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG")
[ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; }
echo "DATABASE_URL=postgresql+psycopg://tradein:tradein@${ip}:5432/tradein" >> "$GITHUB_ENV"
echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)"
# Тот же порядок и тот же строгий режим, что в deploy-tradein.yml:
# `ls | sort` + ON_ERROR_STOP=on, падение любой миграции → job RED.
# Никаких «применилось как получилось»: схема в CI либо та же, что на
# проде, либо гейта нет.
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q -c \
"CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE ROLE gendesign_reader;"
for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do
fname=$(basename "$sql_file")
# ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а
# backfill, читающий foreign table gendesign_rosreestr_deals из БД
# ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING
# создать не из чего. На пустых таблицах backfill всё равно no-op.
if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then
echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет"
continue
fi
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \
|| { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; }
done
echo "✓ схема собрана: $(docker exec "$CI_PG" psql -U tradein -d tradein -tAc \
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public'") таблиц"
- name: Install uv
# Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается
# на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI).
@ -81,23 +183,104 @@ jobs:
restore-keys: |
uv-tradein-${{ runner.os }}-
- name: Sync deps (incl. dev group — pytest)
- name: Sync deps (incl. dev group — pytest, ruff)
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
# вверх от cwd.
run: uv sync --frozen
- name: Lint (ruff check)
# Правила выбраны в tradein-mvp/backend/pyproject.toml ([tool.ruff.lint]
# select = E F I B UP N RUF), но до этого шага их никто не гонял в CI —
# "дерево чистое" было непроверенным утверждением, а не гарантией.
# Версия ruff — та же, что в tradein-mvp/uv.lock (--frozen из шага выше),
# т.е. ровно то, что видит `uv sync --frozen` в Dockerfile.
# Blocking: любое нарушение → job RED (не декоративно).
run: uv run ruff check .
- 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) убран — проходит в
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан
# совпадать с test-job в deploy-tradein.yml.
run: |
uv run pytest -q \
--deselect "tests/test_search_api.py::test_search_cache_hit"
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
#
# Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit`
# с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
# global-state leak из другого модуля». Объяснение было неверным в обеих
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что он —
# единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка
# X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что
# фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке;
# заголовок добавлен, deselect снят, полный прогон зелёный.
#
# Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же
# класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
# помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом.
#
# NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого
# deselect'а — он остаётся до #2680, который правит тот файл. Расхождение
# безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный.
#
# `-rs` (#2745) — КАЖДЫЙ пропуск печатает свою причину в лог job'а. Без него
# `-q` рисует пропуск точкой `s`, неотличимой на глаз от прогона: ровно так
# девять DB-тестов «шли зелёными», ничего не проверяя. Пропуск, который не
# называет себя вслух, со временем перестаёт быть верным.
run: uv run pytest -q -rs
- name: Снести тестовый Postgres
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог
# вне uv-воркспейса, так что и `uv run pytest` из backend их не собирал. Итог:
# 4 теста лежали красными на main (с 2026-06-20 и 2026-07-02), файл при этом
# правился, и никто не узнал. Починка — PR #2724, этот job закрывает причину.
#
# Почему НЕ переиспользуем backend-job:
# 1. сайдкар не член воркспейса → `uv sync --frozen` его не ставит;
# 2. aiohttp (единственная не-stdlib зависимость сьюта) нет в tradein-mvp/uv.lock;
# 3. разный scope paths-filter: правка browser/ не должна гонять backend-сьют.
browser-tests:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.browser == 'true'
# Сьют идёт ~15с. Лимит — страховка от зависшего теста: у сайдкара нет своего
# pyproject, а значит и pytest-timeout'а backend'а (timeout=120). Дешевле
# взять нативный job-таймаут, чем тащить плагин ради одного каталога.
timeout-minutes: 10
defaults:
run:
working-directory: ./tradein-mvp/browser
steps:
- uses: actions/checkout@v4
- name: Set up Python
# 3.12 — как в browser/Dockerfile (FROM python:3.12-slim).
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install test deps
# ВЕСЬ список: pytest + aiohttp. Ни playwright, ни camoufox, ни закачки
# Firefox — camoufox импортируется ЛЕНИВО внутри _launch_browser
# (server.py, `from camoufox.async_api import AsyncCamoufox`), а сами тесты
# мокают _ensure_browser/_do_fetch и грузят server.py по пути через importlib.
# pytest-asyncio тоже НЕ нужен: ни одного `async def test_` — каждый тест сам
# крутит asyncio.run(). Проверено локально на venv ровно из этих двух пакетов.
#
# aiohttp без пина — ровно как в browser/Dockerfile (`pip install ... aiohttp`),
# то есть гейт видит ту же версию, что уедет в образ. Пин здесь означал бы
# проверку версии, которой в проде нет.
run: pip install pytest aiohttp
- name: Run pytest (tradein-mvp/browser)
# Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего
# не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped.
# `-rs`: если однажды появится пропуск, он назовёт причину в логе, а не
# растворится в строке точек.
run: pytest -q -rs
frontend-checks:
runs-on: ubuntu-latest
@ -111,25 +294,48 @@ 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
- name: Mera-public isolation guard (#2631)
# Blocking: статический import-graph публичного лэндинга не должен
# достигать закрытого контура (useMe/lib/api/sessionId/isPathAllowed/
# GuardedRoute вне next/dynamic). Инвариант этапа 1 #2545.
run: npm run check:mera-public-isolation

View file

@ -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:
@ -52,6 +81,14 @@ jobs:
backend:
- 'backend/**'
- 'data/sql/**'
# auth/roles.yaml — общий RBAC-конфиг ОБОИХ стеков (bind-mount в
# backend и в tradein-backend). Правка ролей/пользователей меняет
# поведение backend/tests/test_rbac.py, но сам файл лежит вне
# 'backend/**' → без этой строки сьют no-op'ился, и правка уезжала
# в main без единого прогона. Так и случилось 2026-07-30: user2
# переведён в expired, test_get_role_known_users стал красным и
# доехал до main незамеченным (починен в PR #2587).
- 'auth/**'
- '.forgejo/workflows/ci.yml'
frontend:
- 'frontend/**'
@ -61,6 +98,20 @@ jobs:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true'
# Postgres-сервис (#2745). Раньше DATABASE_URL указывал на заведомо мёртвый
# хост, и весь tests/sql/ (10 тестов: #17 velocity-alerts, #99 ДДУ-индикатор,
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
# не бежали ни разу с момента написания.
#
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
# нужен, в отличие от tradein-лэйна.
#
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
# SSH-туннелю. Пустой контейнер дал бы там красноту на пустом месте, поэтому
# integration остаётся честно пропущенным — с причиной в логе (`-rs`).
defaults:
run:
working-directory: backend
@ -68,14 +119,53 @@ jobs:
# TESTING=1 активирует RBAC-bypass (app/main.py rbac_guard пропускает
# запросы при settings.testing=True) — иначе 401 на всём /api/v1.
TESTING: "1"
# Stub DSN: psycopg v3 требует parseable URL на импорте; реального коннекта
# нет — DB-тесты мокаются, real-DB тест (tests/sql/) self-skip'ается через
# connectivity-probe к этому хосту (5432 недоступен → skip).
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379/0
# Имя контейнера уникально на прогон: параллельные PR не дерутся за него.
CI_PG: ci-pg-backend-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- name: Поднять Postgres для тестов
working-directory: .
# ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА — подробный разбор в
# ci-tradein.yml (тот же раннер). Кратко: job и сервис-контейнеры идут с
# `--network host`, а на 5432 этого хоста слушает ПРОДОВЫЙ Postgres, то
# есть `localhost:5432` из job'а — боевая база. Поднимаем контейнер сами,
# в bridge-сети, без публикации порта, ходим по его IP.
#
# `pg_isready -h 127.0.0.1`, а не через unix-сокет: по сокету отвечает
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
#
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
# здесь не нужен вовсе, в отличие от tradein-лэйна.
run: |
set -u
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
docker run -d --name "$CI_PG" \
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
postgres:16
ready=""
for _ in $(seq 1 45); do
if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U gendesign -q 2>/dev/null; then
ready=1; break
fi
[ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break
sleep 2
done
if [ -z "$ready" ]; then
echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)"
docker logs --tail 50 "$CI_PG" 2>&1 || true
exit 1
fi
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG")
[ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; }
echo "DATABASE_URL=postgresql+psycopg://gendesign:gendesign@${ip}:5432/gendesign_ci" >> "$GITHUB_ENV"
echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)"
- name: Set up Python
uses: actions/setup-python@v5
with:
@ -128,10 +218,13 @@ jobs:
# но --ignore — belt-and-suspenders на случай сбора фикстур).
# tests/integration self-skip'ается через requires_test_db (skipif на
# TEST_DATABASE_URL, который тут не задан) → НЕ игнорим, оно чисто skip'ается.
# tests/sql/ mv_layout self-skip'ается через Postgres-connectivity probe
# (5432 недоступен в этом mock-lane) → SKIP. Это intended.
# tests/sql/ теперь РЕАЛЬНО ИДУТ — postgres-контейнер выше (#2745).
# PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK.
#
# `-rs` (#2745): каждый оставшийся пропуск печатает причину. Под `-q` без
# него пропуск неотличим от прогона — именно так проверка тихо перестаёт
# исполняться и об этом узнают, когда на неё надо опереться (#2722/#2729/#2740).
#
# Coverage-gate (#68): --cov=app меряет покрытие пакета app/.
# --cov-fail-under=65 → job RED если покрытие упало ниже baseline
# (измерено 2026-06: mock-lane сьют ~71%, см. [tool.coverage] в pyproject;
@ -140,11 +233,18 @@ jobs:
# coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge).
# term-missing → видно непокрытые строки прямо в job-логе.
run: |
uv run pytest -q --ignore=tests/smoke \
# #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит
# в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как
# «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется:
# ниже `exit $rc`.
rc=0
uv run pytest -q -rs --ignore=tests/smoke \
--cov=app \
--cov-report=term-missing:skip-covered \
--cov-report=xml:coverage.xml \
--cov-fail-under=65
--cov-fail-under=65 || rc=$?
echo "### pytest вернул код $rc"
exit $rc
- name: Coverage summary → job output
# Дешёвый human-readable итог. Бежит даже если gate упал (if: always) —
@ -153,13 +253,34 @@ jobs:
# если переменная пустая/файла нет, печатаем в обычный лог (fallback).
if: always()
run: |
echo "### шаг «Coverage summary» начался"
[ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; }
report="$(uv run coverage report --skip-covered --sort=cover | tail -40)"
# NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с
# кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` —
# то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал
# неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден.
# `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после
# упавшей команды просто не выполнится, и код возврата снова потеряется.
cov_rc=0
uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$?
echo "### coverage report вернул код $cov_rc"
report="$(tail -40 /tmp/cov_report.txt)"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{ echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
else
echo "$report"
fi
echo "### шаг «Coverage summary» закончился успешно"
- name: Снести тестовый Postgres
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
if: always()
working-directory: .
run: |
echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})"
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
echo "### шаг «Снести тестовый Postgres» закончился успешно"
frontend-tests:
runs-on: ubuntu-latest

View file

@ -28,12 +28,28 @@ jobs:
frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }}
browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }}
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
scraper: ${{ steps.set-all.outputs.scraper || steps.filter.outputs.scraper }}
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
app_version: ${{ steps.build-meta.outputs.app_version }}
build_sha: ${{ steps.build-meta.outputs.build_sha }}
build_date: ${{ steps.build-meta.outputs.build_date }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
# правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
# проброшены как build-args в build-backend/build-frontend ниже (см.
# tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
# Считается ОДИН раз здесь, а не в каждой job отдельно.
- name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
id: build-meta
run: |
echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
# The file is written by the deploy job on every successful deploy.
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
@ -91,7 +107,6 @@ jobs:
echo "frontend=true" >> "$GITHUB_OUTPUT"
echo "browser=true" >> "$GITHUB_OUTPUT"
echo "infra=true" >> "$GITHUB_OUTPUT"
echo "scraper=true" >> "$GITHUB_OUTPUT"
# Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges
# (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes.
@ -107,28 +122,40 @@ jobs:
# scheduler_main импортирует пакет) — kit-only изменение обязано
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
- 'tradein-mvp/packages/scraper-kit/**'
# APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
# ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
# правок кода обязан пересобрать образ, иначе GET /version и колонтитул
# PDF продолжат отдавать старое значение при формально «успешном» деплое.
- 'tradein-mvp/VERSION'
frontend:
- 'tradein-mvp/frontend/**'
# NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
# причина, что у backend выше.
- 'tradein-mvp/VERSION'
# /versions статически запекает CHANGELOG.md в билд (см.
# frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
# frontend/** иначе не долетала бы до образа.
- 'tradein-mvp/CHANGELOG.md'
browser:
- 'tradein-mvp/browser/**'
infra:
- 'tradein-mvp/docker-compose.prod.yml'
- 'tradein-mvp/deploy/**'
- '.forgejo/workflows/deploy-tradein.yml'
scraper:
- 'tradein-mvp/backend/app/services/scrapers/**'
- 'tradein-mvp/backend/app/services/scrape_pipeline.py'
- 'tradein-mvp/backend/app/services/scheduler.py'
- 'tradein-mvp/backend/app/scheduler_main.py'
- 'tradein-mvp/backend/app/tasks/**'
# #2188: scheduler исполняет matching/dedup при каждом scrape-тике —
# без этих путей scraper-контейнер оставался на старом коде
# (2026-07-02: fias-dedup доехал до tradein-backend, но не до
# tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть
# scheduler — его правки тоже обязаны пересоздавать контейнер.
- 'tradein-mvp/backend/app/services/matching/**'
- 'tradein-mvp/backend/app/services/house_dedup_merge.py'
- 'tradein-mvp/packages/scraper-kit/**'
# УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом
# «файлов, которые исполняет планировщик», и перечислял только то,
# что вспомнили. Дважды выстрелило одинаково:
# 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не
# до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**,
# house_dedup_merge.py) — залатали случай, не механизм;
# 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py
# в списке не значились → планировщик час крутил старый код,
# деплой при этом отчитался успехом.
# За июнь-август 48% (193 из 402) backend-мержей не попадали ни в
# один из путей списка, т.е. половина правок доезжала до scraper'а
# только со следующим «удачным» деплоем. Теперь пересоздание
# привязано не к списку файлов, а к факту пересборки образа —
# см. SCRAPER_RECREATE в job deploy.
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
@ -164,15 +191,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
@ -190,10 +227,49 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-backend
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# валит весь шаг ДО push нового образа — деплой тогда молча
# пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай
# без cache-from — ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
@ -201,12 +277,65 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push tradein-backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2,
# issue #2): успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча
# оставалась навсегда, следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше).
# Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при
# упавшем build — этот шаг единственный это заметит: манифеста с этим SHA
# не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy
# ниже пропускается вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
@ -223,10 +352,55 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
# поэтому копируем внутрь ДО build. /versions статически запекает его
# содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
# комментарий). Не влияет на кэш другого шага — читается только этим.
- name: Stage CHANGELOG.md into frontend build context
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
- name: Build & push tradein-frontend
# id + continue-on-error — см. tradein-backend (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
@ -236,15 +410,62 @@ jobs:
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
# уходит в notFound (404), не индексируется и не краулится.
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/frontend
push: true
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-browser:
runs-on: ubuntu-latest
needs: changes
@ -263,10 +484,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push tradein-browser
# id + continue-on-error — см. tradein-backend выше (#2841): битый blob
# в удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
@ -277,6 +535,41 @@ jobs:
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Retry build & push tradein-browser без кеша (битый buildcache, #2841)
# См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to
# ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими
# слоями (mode=max), это и есть самолечение.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./tradein-mvp/browser
push: true
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
- name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. tradein-backend выше — не полагается на steps.build.outcome раннера.
run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, test, build-backend, build-frontend, build-browser]
@ -294,21 +587,32 @@ jobs:
uses: appleboy/ssh-action@v1.0.3
env:
IMAGE_TAG: latest
# Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не
# уходить в drain, когда пересоздавать нечего (см. ниже, #2679).
IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }}
GHCR_PAT: ${{ secrets.GHCR_PAT }}
# Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT
# recreate the scraper and SIGKILL a running multi-hour job. Only genuine
# scraper-code paths (the `scraper` paths-filter already covers
# app/services/scrapers/**, scrape_pipeline.py, scheduler.py,
# scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch —
# should trigger a scraper recreate. (infra term intentionally dropped.)
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }}
# #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ
# gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса,
# одна строка image, разный command). Значит вопрос «пересоздавать ли
# scraper» — это не «трогали ли его файлы», а «мог ли пересобраться
# образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend:
# backend || infra || workflow_dispatch. Ровно тогда в реестре мог
# появиться новый :latest, и оставить scraper на старом — значит
# оставить планировщик на старом коде (инцидент #2679).
#
# Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ
# пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс
# больше не нужен — с #1951 перед recreate'ом идёт graceful drain
# (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам
# `compose up -d` на неизменившемся образе — no-op.
SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
GITHUB_SHA: ${{ github.sha }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED,GITHUB_SHA
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
script: |
set -euo pipefail
cd /opt/gendesign
@ -431,6 +735,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.
@ -489,8 +822,43 @@ jobs:
# с browser/backend/frontend, отдельного graceful-drain не требует.
SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS=""
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
scraper_stale=""
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
# Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE
# истинно и на infra-правках (compose / workflow / deploy/**), а те
# почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется,
# `up -d` выходит no-op — и платить за него пятиминутным drain'ом,
# прерывая многочасовой сбор, не за что. Сравниваем, на том ли
# образе бежит scraper, что уже лежит в локальном демоне.
# ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) —
# до pull'а под тегом :latest ещё старый образ, сравнение всегда
# «совпало» и drain пропускался бы как раз тогда, когда он нужен.
# Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на
# ЭТОТ же признак и больше не выполняются, когда recreate'а не было
# (иначе живой прогон с heartbeat старше чекпоинта помечался бы
# 'cancelled', продолжая работать).
pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "")
running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "")
if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then
echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего,"
echo " drain пропускаем, in-flight прогоны не трогаем"
else
scraper_stale="yes"
fi
# scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d`
# — no-op, но правка самого compose (env/лимиты сервиса) так всё же
# доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а
# страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку
# прогона подчистит периодический 6h zombie-reaper.
SERVICES="$SERVICES scraper"
else
echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем"
echo " (сверка образов ниже всё равно проверит, что он не отстал)"
fi
if [ -n "$scraper_stale" ]; then
echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);"
echo " ждём слива in-flight scrape_runs (до 5 мин)"
drained=""
for i in $(seq 1 30); do
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
@ -527,15 +895,11 @@ jobs:
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
SERVICES="$SERVICES scraper"
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
@ -653,7 +1017,7 @@ jobs:
# снижает шанс поймать контейнер ровно в момент between-restarts
# промежуточного "running" внутри crash-loop.
# tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES);
# scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому
# scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому
# проверяем только то, что реально входит в текущий $SERVICES.
for svc in tgbot scraper; do
case " $SERVICES " in
@ -683,6 +1047,49 @@ jobs:
echo "→ tradein-$svc running."
done
# Сверка образов backend-семейства (#2679) — последняя проверка перед
# маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ
# gendesign-tradein-backend; backend пересоздаётся на каждом деплое
# (безусловно в $SERVICES) и потому всегда несёт свежий :latest —
# он и есть эталон. Если у scraper или tgbot image ID другой, значит
# контейнер остался на старом коде, а деплой без этой проверки
# отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до
# tradein-backend, ff98603ba3cc; tradein-scraper остался на
# da26154c64a6 часовой давности — а планировщик, единственный
# исполнитель домовой оценки, живёт именно там).
# Падаем, а не warning'уем: расхождение = правка не работает, и
# узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт
# ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же
# базу и пересоберёт всё накопленное (тот же приём, что в health-check).
# «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся
# по-разному, поэтому сообщения различаются явно.
backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "")
image_mismatch=""
if [ -z "$backend_image" ]; then
echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем."
image_mismatch="yes"
fi
for svc in scraper tgbot; do
svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "")
if [ -z "$svc_image" ]; then
echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший"
echo " образ, а неполный стек: сервис не работает вообще."
image_mismatch="yes"
elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then
echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image"
image_mismatch="yes"
fi
done
if [ -n "$image_mismatch" ]; then
echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)."
echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):"
echo " docker compose -p gendesign-tradein \\"
echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\"
echo " up -d --force-recreate --no-deps backend scraper tgbot"
exit 1
fi
echo "→ образы совпадают: backend/scraper/tgbot на $backend_image."
# Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
ghcr.io/lekss361/gendesign-tradein-frontend; do
@ -697,3 +1104,33 @@ jobs:
# The changes job reads this file on the next run to compute cumulative diff.
echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha
echo "→ Deployed SHA marker updated: $GITHUB_SHA"
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда `test` или один из build-* падает (например,
# битый blob в buildcache роняет `docker/build-push-action` — до ретрая
# выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это
# было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя
# tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`,
# кроме отмены прогона) и сама падает, если deploy не завершился success —
# неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/
# health-check/сверка образов #2679). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
deploy-status:
runs-on: ubuntu-latest
needs: [test, build-backend, build-frontend, build-browser, deploy]
if: always() && !cancelled()
steps:
- name: Итог прогона — деплой обязан быть success, не skipped/failure
run: |
echo "test: ${{ needs.test.result }}"
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "build-browser: ${{ needs.build-browser.result }}"
echo "deploy: ${{ needs.deploy.result }}"
if [ "${{ needs.deploy.result }}" != "success" ]; then
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи test/build-backend/build-frontend/build-browser/deploy выше."
exit 1
fi
echo "✓ деплой прошёл успешно"

View file

@ -16,6 +16,23 @@ on:
- ".forgejo/workflows/deploy.yml"
- "data/sql/**"
- "ops/glitchtip-auth-forwarder/**"
# Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
# до прода до следующего чужого коммита в backend/.
- "ops/db-bootstrap/**"
# RBAC roles config (auth/roles.yaml, bind-mounted read-only ТОЛЬКО в backend —
# см. docker-compose.prod.yml; worker монтирует лишь ./data и ./reports).
# app.core.auth кэширует парсинг на весь lifetime процесса (@lru_cache) — без
# этого триггера правка ролей вступала бы в силу в случайный момент, только на
# следующий чужой деплой (`up -d --force-recreate --no-deps backend worker beat`
# ниже сбрасывает кэш перезапуском процесса; сам файл в образ не запекается,
# ребилда картинок для этого не нужно).
- "auth/**"
# То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM
# по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard`
# шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами
# исполнял бы старую версию — молча и без единого сигнала.
- "ops/docker-prune.sh"
workflow_dispatch:
concurrency:
@ -67,10 +84,49 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push backend (lean — без Chromium)
# id + continue-on-error: битый blob в удалённом buildcache-манифесте
# (registry cache, не local) валит весь шаг ДО push нового образа —
# деплой тогда молча пропускается (#2841), хотя код собрать можно, просто
# без кеша. cache-from нефатален: при падении ретраим БЕЗ него ниже.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./backend
@ -82,6 +138,53 @@ jobs:
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Retry build & push backend без кеша (битый buildcache, #2841)
# cache-from опущен (источник падения), а cache-to ОСТАВЛЕН: успешный
# ретрай пушит свежие слои в buildcache-тег и тем самым сам перезаписывает
# битый blob (mode=max — полная перезапись манифеста). Раньше cache-to был
# опущен и здесь тоже — но следующий обычный прогон опять получает cache-from
# на детерминированно битый тег и падает СНОВА: самолечения не было НИКОГДА
# (ревью #2841 R2, issue #2). Если и retry упадёт — шаг красный БЕЗ
# continue-on-error, job честно FAILURE, и deploy ниже корректно
# пропускается (уже настоящая причина, не кеш).
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner
push: true
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
- name: Проверить, что backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# НЕ полагается на семантику steps.build.outcome/continue-on-error раннера —
# проверяет РЕАЛЬНОЕ состояние registry напрямую через buildx (уже настроен
# выше). Если act_runner не заполняет outcome (не проверено живым прогоном,
# см. ревью), ретрай выше молча НЕ побежит при упавшем build, а этот шаг —
# единственный, кто это заметит: манифеста с этим SHA не будет → шаг падает
# БЕЗ continue-on-error → job честно FAILURE → deploy ниже пропускается
# вместо накатки старого :latest на прод.
run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-worker:
runs-on: ubuntu-latest
needs: changes
@ -98,10 +201,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push worker (с Chromium для Playwright)
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./backend
@ -113,6 +253,45 @@ jobs:
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ github.sha }}
- name: Retry build & push worker без кеша (битый buildcache, #2841)
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
# навсегда — следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner-with-chromium
push: true
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ github.sha }}
- name: Проверить, что worker:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
run: docker buildx imagetools inspect ${{ env.IMAGE_WORKER }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
build-frontend:
runs-on: ubuntu-latest
needs: changes
@ -129,10 +308,47 @@ jobs:
run: |
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Подобрать протёкшие buildx-билдеры (#2869)
# Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно
# (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия,
# ни завершающий шаг не выполняются — контейнер job'а уже мёртв.
# Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца
# (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного
# за пять недель между 05.07 и 13.08, когда аварий не было. Два последних
# созданы 13.08 11:57:43 — ровно тот прогон, что упал с
# `no space left on device`.
# Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не
# принадлежит живому прогону (самый долгий job — ~17 минут).
run: |
now=$(date +%s); reaped=0; kept=0
for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do
created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue
ts=$(date -d "$created" +%s 2>/dev/null) || continue
age_h=$(( (now - ts) / 3600 ))
if [ "$age_h" -ge 6 ]; then
echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)"
if docker rm -f "$c" >/dev/null 2>&1; then
reaped=$((reaped+1))
else
echo "buildx: не удалось убрать $c (не фатально)"
fi
docker volume rm "${c}_state" >/dev/null 2>&1 || true
else
kept=$((kept+1))
fi
done
echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}"
df -h / | tail -1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
- name: Build & push frontend
# id + continue-on-error — см. build-backend выше (#2841): битый blob в
# удалённом buildcache не должен ронять сборку и молча пропускать деплой.
id: build
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: ./frontend
@ -146,6 +362,47 @@ jobs:
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Retry build & push frontend без кеша (битый buildcache, #2841)
# См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН —
# успешный ретрай перезаписывает битый buildcache-тег своими слоями
# (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась
# навсегда — следующий прогон снова падал на том же cache-from.
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
- name: Проверить, что frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2)
# См. backend выше — не полагается на steps.build.outcome раннера, проверяет
# реальное состояние registry, чтобы молча пропущенный ретрай (если outcome
# не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом.
run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null
- name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон)
# setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон.
# Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на
# хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в
# их `_state`-томах — диск ушёл на 94%, деплой упал с
# `no space left on device`. Убираем явно, `if: always()` и `|| true`,
# чтобы уборка не могла уронить прогон.
if: always()
run: |
name="${{ steps.buildx.outputs.name }}"
if [ -z "$name" ]; then
echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)"
exit 0
fi
echo "buildx: убираю билдер $name"
docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)"
deploy:
runs-on: ubuntu-latest
needs: [changes, build-backend, build-worker, build-frontend]
@ -305,6 +562,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.
@ -320,6 +602,70 @@ jobs:
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
fi
# ── БД `auth` — единое хранилище доступов «Меры» и «Птицы» ──────────────
# Расположение файлов: схема лежит в data/sql/auth/ (ПОДКАТАЛОГ, не плоский
# data/sql/) — цикл миграций выше использует `ls -1 data/sql/*.sql`, который в
# подкаталоги не рекурсирует. Значит эти файлы физически не могут примениться
# в БД gendesign, даже если кто-то забудет про разделение; при этом триггер
# `data/sql/**` (paths выше) подкаталог покрывает, деплой запускается сам.
# Свой _schema_migrations живёт ВНУТРИ БД auth: отдельная база — отдельный
# трекинг, имена файлов двух каталогов не конфликтуют между собой.
# Порядок: сразу после bootstrap'а FDW-пароля и ДО `compose up -d` — падение
# здесь останавливает деплой (exit 1) до подъёма нового кода.
# `source backend/.env.runtime` уже выполнен выше (строка с FDW-паролем), из него
# берётся AUTH_DB_PASSWORD.
echo "→ Bootstrapping auth database (idempotent)"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=on \
< ops/db-bootstrap/create_auth_db.sql \
|| { echo "FAILED to create auth database"; exit 1; }
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on -c "
CREATE TABLE IF NOT EXISTS _schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"
for sql_file in $(ls -1 data/sql/auth/*.sql 2>/dev/null | sort); do
fname=$(basename "$sql_file")
# `| tr -d '[:space:]'` — как в deploy-tradein.yml: без него psql-вывод с
# лишним пробелом/CR ломает сравнение с "0" и миграция молча считается
# применённой.
applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -tAc \
"SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" \
| tr -d '[:space:]')
if [ "$applied" = "0" ]; then
echo "→ Applying auth migration: $fname"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
< "$sql_file" \
|| { echo "FAILED on auth migration: $fname"; exit 1; }
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -c \
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
else
echo "✓ Already applied (auth): $fname"
fi
done
echo "All auth migrations applied."
# Пароль роли auth_app из env (post-migration bootstrap): миграция
# data/sql/auth/002_auth_app_role.sql создаёт роль БЕЗ пароля, пароль живёт
# только в /opt/gendesign/backend/.env.runtime. Пустая переменная — не ошибка:
# PR-1 ещё никого не подключает к этой БД, роль просто остаётся без пароля.
if [ -n "${AUTH_DB_PASSWORD:-}" ]; then
echo "→ Applying auth_app password from env"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
-v "pw=$AUTH_DB_PASSWORD" \
< ops/db-bootstrap/set_auth_app_password.sql
else
echo "⚠️ AUTH_DB_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for auth_app"
fi
# Build local-only sidecar images (glitchtip-auth-forwarder).
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.
@ -374,8 +720,50 @@ jobs:
docker image prune -af || true
docker builder prune -af || true
# Health check
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (см. #2214,
# уже сделано так в deploy-tradein.yml; ревью #2841 R2 issue #3).
# `curl ... && break` под set -e НЕ мог провалить скрипт: curl — не
# последняя команда &&-списка, а POSIX прямо освобождает от errexit
# все команды AND/OR-списка кроме последней. После 30 неуспешных
# попыток цикл завершался кодом последнего sleep (0) — скрипт тихо
# продолжался, деплой уходил success с мёртвым бэкендом.
healthy=""
for i in $(seq 1 30); do
curl -fsS http://localhost:8000/health && break
if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
healthy="yes"; break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
exit 1
fi
echo "→ backend healthy на /health."
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда build падает (например, битый blob в
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
# skipped-job НЕ красит прогон явным «FAILED» так, чтобы это было видно на
# первый взгляд — итог выглядит зелёным/нейтральным, хотя прод не обновился.
# Эта job бежит ВСЕГДА (`if: always()`, кроме отмены прогона) и сама падает,
# если deploy не завершился success — неважно, пропущен он (build упал) или
# упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где
# решение реально принято, а не там, где она случайно оказалась по цепочке if.
deploy-status:
runs-on: ubuntu-latest
needs: [build-backend, build-worker, build-frontend, deploy]
if: always() && !cancelled()
steps:
- name: Итог прогона — деплой обязан быть success, не skipped/failure
run: |
echo "build-backend: ${{ needs.build-backend.result }}"
echo "build-worker: ${{ needs.build-worker.result }}"
echo "build-frontend: ${{ needs.build-frontend.result }}"
echo "deploy: ${{ needs.deploy.result }}"
if [ "${{ needs.deploy.result }}" != "success" ]; then
echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \
"Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \
"Смотри логи build-backend/build-worker/build-frontend/deploy выше."
exit 1
fi
echo "✓ деплой прошёл успешно"

View file

@ -0,0 +1,38 @@
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
#
# НЕ pre-merge гейт — эти 4 проверки требуют реального DNS + выпущенного TLS-
# сертификата для meraocenka.ru, т.е. осмысленны ТОЛЬКО против прода после
# деплоя. Запускается вручную (workflow_dispatch) или раз в сутки (cron) —
# страхует от случайной регрессии периметра (например, будущий PR по ошибке
# открывает B2B-путь на публичном домене, или basic_auth gate на gendsgn.ru
# случайно снимают).
#
# ДО того как появится DNS A-record meraocenka.ru → IP VPS, проверки 1 и 2
# (см. scripts/smoke-mera-perimeter.sh) ожидаемо КРАСНЫЕ — это не регресс,
# просто домен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового
# домена и обязаны быть зелёными всегда.
name: perimeter-smoke-mera
on:
workflow_dispatch: {}
schedule:
# Раз в сутки, 06:17 UTC — вне пиков, время произвольное.
- cron: '17 6 * * *'
concurrency:
group: perimeter-smoke-mera
cancel-in-progress: false
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Run perimeter smoke checks
run: |
chmod +x scripts/smoke-mera-perimeter.sh
./scripts/smoke-mera-perimeter.sh

View file

@ -1,91 +0,0 @@
name: CI
on:
push:
branches:
- main
- 'feat/**'
- 'fix/**'
- 'refactor/**'
- 'chore/**'
- 'docs/**'
- 'perf/**'
- 'test/**'
- 'hotfix/**'
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_DB: gendesign
POSTGRES_USER: gendesign
POSTGRES_PASSWORD: gendesign
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U gendesign"
--health-interval 5s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Set up Python
run: uv python install 3.12
- name: Install system deps for geo + WeasyPrint
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0
- name: Install Python deps
run: uv sync
- name: Lint (ruff)
run: uv run ruff check .
- name: Type check (mypy strict on core)
run: |
uv run mypy \
app/services/generative \
app/services/site_finder/scorer.py
- name: Test (pytest)
run: uv run pytest -q
env:
DATABASE_URL: postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: npm ci || npm install
- run: npm run lint
- run: npm run type-check
- run: npm run build

254
Caddyfile
View file

@ -11,6 +11,13 @@
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
#
# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`,
# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ
# import'ау trade-in своя авторизация (форма входа + opaque session-cookie,
# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder
# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для
# него — НЕ трогать caddy/users.caddy.snippet).
#
# IMPORTANT: route { } block is required to preserve directive order.
# Without route { }, Caddy executes directives in hard-coded default order
# (basic_auth runs before handle), making /health and /preview/* exclusions
@ -70,26 +77,56 @@ gendsgn.ru {
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview {
reverse_proxy tradein-frontend:3000
reverse_proxy tradein-frontend:3000 {
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
# (страница статична, у tradein-frontend нет секрета для
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
# а не полагаясь на то, что downstream ничего не делает с заголовком.
header_up -X-Authenticated-User
}
}
# Auth gate (applies to all routes below within this route block).
import caddy/users.caddy.snippet
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
# подключен через gendesign_shared network. Routes ДО универсального handle
# потому что Caddy матчит handle-блоки сверху вниз.
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
# как /health и /preview/* выше).
#
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
# никогда не резолвится авторизованным юзером на этом пути.
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
# префикса basePath /trade-in (Next.js basePath leak).
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -98,6 +135,23 @@ gendsgn.ru {
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
# (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на
# бэке; без валидной сессии юзер получит редирект на /login, а не
# контент. Смысл переноса — не открыть страницу всем, а убрать
# несогласованность: короткий URL не должен быть строже (Caddy
# basic_auth) целевого адреса, к которому и так уже нет
# basic_auth-барьера (только собственный login trade-in).
#
# ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до
# ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не
# показывается (deny в auth/roles.yaml для pilot и analyst + в
# DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт
# на страницу, а гейт стоит на роли — для всех, кроме admin, короткий
# адрес приведёт на NoAccessScreen.
@saleshare path /sale-share /sale-share/
handle @saleshare {
redir /trade-in/sale-share permanent
@ -110,13 +164,17 @@ gendsgn.ru {
handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
header_up X-Authenticated-User {http.auth.user.id}
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
import caddy/users.caddy.snippet
handle /api/* {
reverse_proxy backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
@ -135,6 +193,153 @@ www.gendsgn.ru {
redir https://gendsgn.ru{uri} permanent
}
# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала).
#
# Архитектурное решение: отдельный домен, а НЕ дырка в блоке gendsgn.ru
# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" —
# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*).
# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем
# caddy/users.caddy.snippet), потому что на этом site-блоке B2B-маршрутов
# физически не объявлено — их нечего "открывать". Явно перечислены РОВНО два
# handle (корень "/" + статика Next _next/*), всё остальное — финальный
# catch-all `handle { respond 404 }`. Регресс-тест на эту модель:
# scripts/smoke-mera-perimeter.sh (проверяет, что B2B-путь здесь = 404, а не
# 200/401 — т.е. не был случайно проброшен).
#
# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же
# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится
# на internal-путь /trade-in/mera-public (страница-заглушка,
# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in
# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса,
# это не HTTP-редирект браузера.
#
# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy
# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг
# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy
# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
# автоматический HTTPS в Caddy изолирован per-hostname. Повторные неудачные попытки ДО
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
# validations/hostname/hour) — не критично, просто подождать; `docker volume
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
meraocenka.ru {
encode zstd gzip
log {
output file /var/log/caddy/meraocenka.ru.log
}
# Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на
# полноценную страницу). rewrite добавляет basePath-префикс только для
# Caddy→backend хопа, пользователь /trade-in никогда не видит.
handle / {
rewrite * /trade-in/mera-public
reverse_proxy tradein-frontend:3000 {
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
# тем более может прислать свой X-Authenticated-User. Сейчас
# инертно (лэндинг статичен, backend-вызовов нет), но снимаем
# ради единообразия периметра, а не полагаясь на то, что
# downstream ничего не делает с заголовком — иначе на этапе 5,
# когда откроется публичный /estimate, это станет дырой.
header_up -X-Authenticated-User
}
}
# Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ
# самих документов (оферта ссылается на meraocenka.ru/refund, политика
# возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому
# обязаны резолвиться сами по себе, а не только длинным
# /trade-in/mera-public/<doc>. Обратное направление тоже рабочее: длинный
# путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что
# то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где
# короткого /oferta нет.
#
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
# который в ней указан. Каноничность для поисковиков задана отдельно, через
# `alternates.canonical` на каждой из трёх страниц.
#
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
# путь проксируется» ради трёх страниц нельзя.
@meraLegalDocs path /oferta /refund /privacy
handle @meraLegalDocs {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
# документ был бы недоступен с публичной страницы.
#
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
# в scripts/smoke-mera-perimeter.sh.
handle /trade-in/mera-public/* {
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
#
# Именно `static/*`, а не весь `_next/*` — тот же матчер, что у @uipreview
# (:78), который в проде доказал, что этого хватает для рендера. Широкий
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
handle /trade-in/_next/static/* {
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# #2631: favicon — единственный корневой статик, который браузер запрашивает
# сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next
# по корневому пути через basePath /trade-in.
handle /favicon.ico {
rewrite * /trade-in/favicon.ico
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
handle {
respond 404
}
}
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации
# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух
# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не
# matcher внутри основного: Caddy матчит по hostname и выпускает свой
# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе
# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но
# лучше завести записи сразу).
# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=.
merahome.ru {
redir https://meraocenka.ru{uri} permanent
}
meraotsenka.ru {
redir https://meraocenka.ru{uri} permanent
}
# Obsidian Self-hosted LiveSync (CouchDB backend).
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).
@ -164,25 +369,6 @@ errors.gendsgn.ru {
}
}
# Uptime Kuma — self-hosted uptime monitoring + public status page (#75 B6-1).
# DNS: A-record status.gendsgn.ru → IP VPS (добавить перед деплоем стека).
# Контейнер из docker-compose.uptime.yml (project gendesign-uptime) на shared
# gendesign_shared network. Если стек не запущен — Caddy отдаёт 502 ТОЛЬКО на
# этом домене, main-сайт не страдает (как obsidian.gendsgn.ru).
#
# ВНИМАНИЕ: status-page НАМЕРЕННО публичен (trust-building для пилотов, issue #75).
# Admin-панель Kuma (/dashboard, /manage-*) защищена собственным логином Kuma —
# НЕ кладём её за caddy/users.caddy.snippet, иначе double-auth сломает setup.
status.gendsgn.ru {
encode zstd gzip
reverse_proxy uptime-kuma:3001
log {
output file /var/log/caddy/status.gendsgn.ru.log
}
}
# Forgejo — self-hosted git (migration 2026-05-16).
# DNS: A-record git.gendsgn.ru → IP VPS.
# Forgejo container из forgejo-migration/docker-compose.yml на shared

View file

@ -85,12 +85,10 @@ docker-compose.prod.yml main стек (backend, frontend, postgres, redis, work
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
├── ci.yml lint (ruff) + mypy + pytest на PR
├── ci.yml lint (ruff) + pytest на PR
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations)
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
```
---
@ -158,7 +156,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
**Forgejo Actions deploys** (self-hosted `git.gendsgn.ru`, мигрировано с GitHub Actions 16.05.2026):
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + pytest (coverage gate ≥65%). mypy strict в гейте не гоняется (доступен вручную — `uv run mypy app/services/generative app/services/site_finder/scorer.py`). Блокирует merge при провале.
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*

View file

@ -39,6 +39,39 @@ roles:
- "/admin/**"
- "/api/v1/admin/**"
- "/trade-in/api/v1/admin/**"
# Внутренние разделы, закрытые от клиентских аккаунтов (решение владельца
# продукта 2026-07-31): «Доля в продаже» — аналитика рынка, «Кэш» —
# состояние кэшей/скраперов. Зеркало deny-списка DB-ролей employee/manager
# (tradein-mvp/backend/app/services/auth_session.py: DB_ROLE_PATHS).
#
# Зачем копия здесь, если клиенты ходят session-cookie'ой: снаружи легаси
# trusted-header ветка НЕДОСТИЖИМА — с #2558 Caddy срезает входящий
# X-Authenticated-User на всём /trade-in/* (`header_up
# -X-Authenticated-User` в handle /trade-in/api/* и в @tradein), так что
# ни один клиентский аккаунт по ней не ходит. Паттерны нужны для другого:
# 1) ВНУТРИСЕТЕВОЙ dual-mode трафик — запросы изнутри gendesign_shared с
# валидным X-Internal-Auth-Secret; ими ходят QA-смоуки вида
# `docker exec tradein-backend curl localhost:8000
# -H 'X-Authenticated-User: ...'` — они резолвятся именно через
# roles.yaml, и без этих строк смоук показал бы 200 там, где
# реальный клиент получает 403;
# 2) чтобы legacy-pilot не расходился с DB-employee, если dual-режим
# когда-нибудь снова окажется на периметре (откат #2558 / новый
# фронт-прокси) — тогда расхождение молча откроет разделы.
# НЕ удалять как «мёртвые»: они мёртвые только пока Caddy режет заголовок.
#
# Страницы + их API вместе: deny гейтит пункт меню (Topbar через /me),
# саму страницу (RouteGuard) и серверные ручки (rbac_guard).
#
# cache-stats закрыт ГЛОБОМ, а не точным путём, намеренно: точный паттерн
# обходится трейлинг-слэшем ('…/cache-stats/' не равен '…/cache-stats' →
# allowed), и защита повисала бы на Starlette redirect_slashes, а не на
# RBAC. '<prefix>/**' → '^<prefix>(?:/.*)?$': сам путь + слэш + подпути,
# но НЕ соседи по префиксу ('…/cache-statistics' не матчится).
- "/trade-in/sale-share/**"
- "/trade-in/cache/**"
- "/trade-in/api/v1/buildings/**"
- "/trade-in/api/v1/trade-in/cache-stats/**"
analyst:
# #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports,
# site-finder, analytics, concept) КРОМЕ admin/data-management.
@ -48,12 +81,28 @@ roles:
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
# /admin/** страниц.
# Клиентский deny 2026-07-31 (см. pilot выше) распространён на analyst
# ЧАСТИЧНО — асимметрия намеренная, не недосмотр:
# «Поиск домов» (/trade-in/sale-share + /api/v1/buildings/**) — ЗАКРЫТ.
# Решение владельца продукта 2026-07-31: это ТЕСТОВЫЙ продукт, доступ
# только у admin. «Только у админа» = включая внутренние роли, поэтому
# analyst тоже в deny.
# «Кэш» (/trade-in/cache + cache-stats) — ОСТАВЛЕН открытым: это не
# продукт, а диагностика состояния кэшей/скраперов, т.е. ровно тот
# рабочий инструмент, ради которого роль analyst и заведена
# («видит ВСЁ кроме admin-управления», см. выше).
# Обе стороны этой асимметрии запиннены тестом
# tradein-mvp/backend/tests/test_rbac.py::test_yaml_roles_deliberately_outside_client_deny
# — если решение поменяется, тест упадёт и заставит обновить и его, и этот
# комментарий, а не тихо разойтись с реальностью.
paths:
- "/**"
deny:
- "/admin/**"
- "/api/v1/admin/**"
- "/trade-in/api/v1/admin/**"
- "/trade-in/sale-share/**"
- "/trade-in/api/v1/buildings/**"
expired:
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
@ -70,7 +119,8 @@ users:
admin: admin
kopylov: pilot
user1: pilot
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
user2: expired # «Брусника» — доступ закрыт 2026-07-30 (решение владельца продукта;
# ранее: восстановлен 2026-07-13, trial-expire 2026-07-09)
user3: pilot
user4: pilot
user5: pilot

View file

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

2
backend/.gitignore vendored
View file

@ -1 +1,3 @@
.coverage
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
coverage.xml

View file

@ -2189,12 +2189,21 @@ def analyze_parcel(
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
_weights_source = "inline"
else:
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_weights_source = (
"profile"
if profile_id is not None
else ("user_default" if profile_user_id is not None else "system")
)
# Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811):
# profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и
# тогда веса системные или дефолтные, а не профильные.
_resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_effective_weights = _resolved.weights
_weights_source = _resolved.source
# «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит,
# был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" —
# иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли».
# None когда profile_id не передавали; False когда передали, но применилось
# другое (не найден / чужой / перебит inline-весами).
_requested_profile_applied: bool | None = (
None if profile_id is None else _weights_source == "profile"
)
# 4) Scoring: weighted sum с distance decay
score = 0.0
@ -2310,12 +2319,31 @@ def analyze_parcel(
-- (303 строки = 303 distinct) COUNT(*) по дедуп-физлотам корректен.
SELECT
np.domrf_obj_id,
ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
-- #2464-D: границы правдоподобия, как в двух соседних запросах
-- по этой же таблице (BETWEEN 30000 AND 600000) здесь их не было.
-- Замер 13.08 по проду ЧЕРЕЗ ЭТОТ ЖЕ ПУТЬ (physflat-дедуп +
-- маппинг на domrf_obj_id): вне диапазона 204 лота из 2 279 827,
-- из них 118 в 10 замапленных проектах и 86 в незамапленных.
-- Эффект сегодня МАЛЫЙ: меняются 6 проектов из 308, худший на
-- 2.4%, market_avg_price (среднее средних) 138 056 138 008;
-- NULL не появляется нигде. Ставим границы не ради этих 48 ,
-- а потому что среднее считается ПО ПРОЕКТУ и один лот держит
-- группу без ограничения сверху: максимум в таблице
-- 19 198 429 /м² (ЖК «Дебют»), и он вне экрана только потому,
-- что проект пока не замаплен (замаплено 308 имён из 881, список
-- растёт). Одна строка маппинга и это число на экране.
-- FILTER, а не WHERE: строки нужны целиком, иначе поедут
-- units_sold / units_available, считающие ВСЕ лоты.
ROUND(AVG(oll.price_per_m2_rub) FILTER (
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available,
-- Считаем ТУ ЖЕ популяцию, что кормит среднее: иначе счётчик
-- обещал бы выборку шире, чем на самом деле участвовала.
COUNT(*) FILTER (
WHERE oll.price_per_m2_rub IS NOT NULL
WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000
) AS lots_with_price
FROM nearby_projects np
JOIN obj_lots_latest oll
@ -4085,9 +4113,12 @@ def analyze_parcel(
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
"program_alternatives": program_alternatives,
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
# source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли
# удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали.
"weights_profile": {
"source": _weights_source,
"profile_id": profile_id,
"requested_profile_applied": _requested_profile_applied,
"user_id": profile_user_id,
"weights_applied": _effective_weights,
"inline_weights": _inline_weights,
@ -4203,6 +4234,7 @@ def analyze_parcel(
"profile_user_id": profile_user_id,
"inline_weights": _inline_weights,
"weights_source": _weights_source,
"requested_profile_applied": _requested_profile_applied,
"x_session_id": _session_id,
},
district=_district_name,

269
backend/app/core/auth_db.py Normal file
View file

@ -0,0 +1,269 @@
"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»).
Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине:
`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в
теле модуля, db.py:8). Сделай мы так же для БД `auth` приложение начало бы
падать на старте везде, где реестр не сконфигурирован: локально, в pytest и на
любом стенде, где переменных AUTH_* нет. Здесь engine создаётся ЛЕНИВО, при
первом реальном обращении.
Контракт ( после мержа прод обязан работать ТОЧНО как сейчас Caddy basic_auth
ещё стоит и снимается последним PR эпика):
* `AUTH_MODE=legacy` (ДЕФОЛТ; `settings.auth_session_enabled is False`) в этот
модуль не заходит никто: `app.main.rbac_guard` в этом режиме куку не читает
вовсе. Пустая конфигурация БД `auth` при этом не ошибка ни на импорте, ни в
рантайме; ни одно соединение с БД `auth` не открывается.
* Режим включён (`dual`/`db_only`) + не сконфигурированный реестр обращение поднимает
`AuthDatabaseNotConfiguredError` с внятным текстом. Именно исключение, а НЕ
тихий возврат «сессия не найдена»: молчаливая деградация означала бы, что все
владельцы валидных кук выглядят как анонимы, то есть массовый отказ доступа
под видом «просто не залогинен» либо, если guard в этот момент откатывается
на trusted-header, наоборот, раздача прав в обход реестра (включая аккаунты с
access_state 'disabled'). Оба исхода обязаны быть громкими.
«Птица» реестр только ЧИТАЕТ: сессии выдаёт и отзывает единственная форма входа
у «Меры». Здесь нет и не должно появиться ни create-, ни revoke-пути.
Сам DSN этот модуль НЕ выбирает и НЕ склеивает берёт готовый у
`settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из
`AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто).
В DSN пароль роли `auth_app`. Он не логируется и не попадает в текст
исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже константы, а ошибку
разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и
заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в traceback.
Добавляешь сюда `logger`/`raise ... {dsn}` не добавляй.
`create_engine` сам по себе к серверу не ходит (пул коннектов ленивый) то есть
одна лишь сборка engine доказывает только «DSN не пуст и парсится». Поэтому
`require_auth_db_configured` (fail-fast старта) дополнительно ОТКРЫВАЕТ соединение
и делает `SELECT 1`: неверный пароль, опечатка в хосте, отсутствующая БД и
отозванная роль обязаны ронять деплой, а не превращаться в «ни у кого нет сессии».
Зеркало по подходу: tradein-mvp/backend/app/core/auth_db.py («Мера»). Синхронизация
руками стеки разные, общего кода между ними нет и заводить его этот эпик не
собирается.
"""
from __future__ import annotations
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import Engine, create_engine, text
from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
class AuthDatabaseNotConfiguredError(RuntimeError):
"""`AUTH_MODE` не `legacy`, а DSN БД `auth` не задан/не разобрался."""
class AuthDatabaseUnreachableError(RuntimeError):
"""DSN синтаксически корректен, но соединиться по нему не удалось (старт приложения)."""
_NOT_CONFIGURED_MSG = (
"Приём сессионной куки включён (AUTH_MODE=dual|db_only), но реестр людей "
"(БД `auth`) не сконфигурирован: пусты и AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — "
"подключаться не к чему. Задай в backend/.env.runtime AUTH_DB_PASSWORD (пароль "
"роли auth_app; остальные части DSN — AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/"
"AUTH_DB_USER — имеют прод-дефолты), либо целиком AUTH_DATABASE_URL, либо верни "
"AUTH_MODE=legacy (сегодняшнее поведение: Caddy basic_auth + заголовок "
"X-Authenticated-User)."
)
_UNREACHABLE_MSG = (
"Приём сессионной куки включён (AUTH_MODE=dual|db_only), DSN разобрался, но "
"соединиться с БД `auth` не удалось (см. причину ниже: хост/порт/база/роль/пароль "
"или сеть). Старт прерван намеренно: иначе сломанная конфигурация выглядела бы как "
"«ни у кого нет сессии» — сутками, при живом приложении и 200-х в ответах. Проверь "
"AUTH_DB_* в backend/.env.runtime и пароль роли auth_app (data/sql/auth/002), либо "
"верни AUTH_MODE=legacy."
)
# Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную
# ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`.
_MALFORMED_DSN_MSG = (
"DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан "
"явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема "
"обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ "
"подставлен: в нём пароль роли auth_app."
)
# Кеш engine/factory + защита от гонки: rbac_guard будет резолвить сессию на каждом
# non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты
# уходят в threadpool). Без лока два одновременных первых запроса создали бы два
# engine — то есть два независимых пула коннектов, один из которых потеряется.
_LOCK = threading.Lock()
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def _build() -> tuple[Engine, sessionmaker[Session]]:
"""Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка.
DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*)
здесь только «пусто или нет» и создание engine.
`pool_size`/`max_overflow` не переопределяем: дефолтов SQLAlchemy (5+10) хватает
с запасом на запрос приходится один короткий SELECT, а раз в 5 минут ещё и
UPDATE sliding-refresh.
А вот таймауты переопределяем, и это не тюнинг, а требование: реестр НЕ
критический путь «Птицы», его сбой обязан деградировать за секунды, а не за
минуты (в dual-режиме деградация уход на легаси-заголовок, в db_only 401).
* `connect_timeout=3` (libpq, секунды). Без него дропнутые SYN (хост поднят, но
недоступен по сети / фаервол молча глотает пакеты) держат попытку соединения
до TCP-таймаута ОС на Linux порядка 130 с. `pool_pre_ping=True` делает такую
попытку на КАЖДОМ checkout'е.
* `statement_timeout=3000` (мс, серверный). Ограничивает уже установленное
соединение: залипший SELECT/UPDATE в auth-пути не имеет права висеть дольше.
* `pool_timeout=3` ожидание свободного коннекта в пуле. Дефолтные 30 с в
auth-пути не нужны никогда: лучше быстро сдаться.
Резолв сессии в rbac_guard уходит в threadpool (`run_in_threadpool`), так что эти
ожидания не блокируют event loop, но они всё равно держат worker-поток и время
ответа, поэтому короткие.
"""
dsn = settings.resolved_auth_database_url
if not dsn:
raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG)
try:
engine = create_engine(
dsn,
pool_pre_ping=True,
future=True,
pool_timeout=3,
connect_args={"connect_timeout": 3, "options": "-c statement_timeout=3000"},
)
except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
# `int(port)` и падает с `invalid literal for int() with base 10: 'w'`, где
# 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None` обязателен: он
# гасит цепочку, иначе исходная ошибка (а с ней и этот кусок) печатается в
# traceback как «During handling of...».
raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
return engine, factory
def _ensure_built() -> tuple[Engine, sessionmaker[Session]]:
global _engine, _session_factory
# Быстрый путь читает глобалы РОВНО ОДИН раз, в локальные переменные. Читать их
# второй раз в `return` нельзя: между проверкой и возвратом может вклиниться
# `reset_auth_db()` (обнуляет оба под локом) — и функция вернула бы (None, None),
# то есть вызывающий упал бы на `factory()` → `TypeError: 'NoneType' object is not
# callable` прямо в auth-пути.
engine, factory = _engine, _session_factory
if engine is not None and factory is not None:
return engine, factory
with _LOCK:
if _engine is None or _session_factory is None:
_engine, _session_factory = _build()
return _engine, _session_factory
def get_auth_engine() -> Engine:
"""Engine БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
engine, _ = _ensure_built()
return engine
def get_auth_session_factory() -> sessionmaker[Session]:
"""Session-factory БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
_, factory = _ensure_built()
return factory
@contextmanager
def auth_session() -> Iterator[Session]:
"""Сессия к БД `auth`, закрывается на выходе из блока.
Это НЕ `app.core.db.get_db`: там продуктовая БД gendesign, где таблиц
`users`/`sessions` реестра нет. Прямой вызов из роутов не предполагается
ходи через `app.services.auth_session.resolve_session_token()`.
"""
factory = get_auth_session_factory()
with factory() as db:
yield db
def _probe_connection(engine: Engine) -> None:
"""Открывает соединение и делает `SELECT 1`. Вынесено функцией ради тестов.
Отдельная функция, а не две строки в `require_auth_db_configured`: тестам нужна
точка подмены, чтобы проверять ветвление старта, не поднимая Postgres.
"""
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
def require_auth_db_configured() -> None:
"""Fail-fast для старта приложения: включённый режим обязан иметь РАБОЧИЙ реестр.
Вызывается из `lifespan` (`app/main.py:111`). Смысл проверки именно на старте: если
сломанная конфигурация обнаружится только в rbac_guard, там её поймает общий
`except` вокруг резолва сессии, и она будет выглядеть как «ни у кого нет сессии»
сутками, потому что продуктовая БД жива и приложение работоспособно, а сигнал
остаётся только в логах. Дешевле не стартовать.
Проверяется ИМЕННО СОЕДИНЕНИЕ, а не только синтаксис DSN. `create_engine` к серверу
не ходит вовсе (пул ленивый), поэтому одна лишь сборка engine отлавливала бы ровно
два случая «DSN пуст» и «DSN не парсится», а весь класс вероятных ошибок
(неверный AUTH_DB_PASSWORD, опечатка в хосте, не созданная БД `auth`, отозванная
роль auth_app, нет сетевой связности) проходил бы мимо и материализовался как та
самая тихая деградация, ради которой эта функция и заведена. Проба короткая:
`connect_timeout=3` в `_build`.
Цена контейнер не поднимется, пока БД `auth` недоступна. Это осознанно: реестр
живёт на ТОМ ЖЕ сервере, что и продуктовая БД (сервис `postgres` корневого
docker-compose.prod.yml, см. `app/core/config.py`), так что «реестр недоступен, а
продукт работоспособен» состояние вырожденное, а `restart: unless-stopped`
поднимет контейнер, как только Postgres вернётся.
Режим `legacy` (ДЕФОЛТ) no-op: ни проверки DSN, ни создания engine, ни коннекта.
Дефолтное поведение обязано оставаться ровно сегодняшним.
Raises:
AuthDatabaseNotConfiguredError: режим не `legacy`, но DSN пуст или не разобрался.
AuthDatabaseUnreachableError: DSN разобрался, но соединиться не удалось.
"""
if not settings.auth_session_enabled:
return
engine, _ = _ensure_built()
try:
_probe_connection(engine)
except Exception as exc:
# Исходную ошибку СОХРАНЯЕМ в цепочке (`from exc`): в ней хост/порт/роль и
# причина отказа — то, ради чего проверка и делается. Пароля libpq в тексте
# ошибок не печатает, а наш DSN сюда не подставляется (см. модульный докстринг).
raise AuthDatabaseUnreachableError(_UNREACHABLE_MSG) from exc
def reset_auth_db() -> None:
"""Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты).
Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а
держать в это время лок незачем ссылки на него уже сняты.
"""
global _engine, _session_factory
with _LOCK:
stale = _engine
_engine = None
_session_factory = None
if stale is not None:
stale.dispose()

View file

@ -1,10 +1,46 @@
import os
import warnings
from typing import Annotated
from typing import Annotated, Literal
from urllib.parse import quote
from pydantic import field_validator, model_validator
from pydantic import SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ─────
# Вынесены константами, потому что используются ДВАЖДЫ: как дефолт поля и как
# запасное значение, если переменная окружения задана ПУСТОЙ строкой
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
#
# ⚠️ ХОСТ — главная ловушка, и для «Птицы» она ЗЕРКАЛЬНА ловушке «Меры».
# У «Меры» (tradein-mvp/backend/app/core/config.py:27) дефолт — `gendesign-postgres`,
# потому что внутри ЕЁ стека имя `postgres` резолвится в её собственный контейнер
# (tradein-mvp/docker-compose.prod.yml:143 собирает им продуктовый DATABASE_URL
# `...@postgres:5432/tradein`), и БД `auth` там нет.
#
# У «Птицы» ровно наоборот: её стек и есть главный. Сервис `postgres` в корневом
# docker-compose.prod.yml:22 (postgis/postgis:16-3.4) — это И ЕСТЬ тот сервер, где
# живёт БД `auth`: bootstrap и миграции data/sql/auth/*.sql применяет к нему шаг
# «Apply DB migrations» в .forgejo/workflows/deploy.yml:339-375. Соседи по тому же
# compose-проекту так к нему и обращаются — `@postgres:5432` (docker-compose.prod.yml:232
# и :265, DATABASE_URL сервисов glitchtip).
#
# Алиас `gendesign-postgres` (docker-compose.prod.yml:43-45) навешен ТОЛЬКО в внешней
# сети `shared` (gendesign_shared) и заведён ради ЧУЖИХ стеков — им и пользуется
# «Мера». Ставить его дефолтом здесь нельзя: в сети `shared` состоят лишь backend и
# worker (`networks: [default, shared]`, строки 152 и 199), а `beat` (строки 201-217)
# сетей не объявляет вовсе — он только в `default`, и `gendesign-postgres` из него
# просто не разрезолвится. `postgres` резолвится из всех трёх.
#
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера. Публикация `127.0.0.1:5432:5432`
# (docker-compose.prod.yml:31-32) существует только ради SSH-туннеля с хоста и к
# этому пути отношения не имеет.
_AUTH_DB_DEFAULT_HOST = "postgres"
_AUTH_DB_DEFAULT_PORT = 5432
_AUTH_DB_DEFAULT_NAME = "auth"
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege: SELECT/
# INSERT/UPDATE/DELETE на sessions, SELECT + column-level UPDATE на users).
_AUTH_DB_DEFAULT_USER = "auth_app"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -110,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 «Доступ заблокирован», БЕЗ
@ -371,5 +404,201 @@ class Settings(BaseSettings):
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
dadata_timeout_s: float = 8.0
# ── Эпик «единый вход»: «Птица» ПРИНИМАЕТ сессию общего реестра ────────────
# Форма входа во всём продукте одна и живёт у «Меры» (/trade-in/login): она
# проверяет пароль и выдаёт сессию в auth.sessions. «Птица» сессии НЕ выдаёт и
# НЕ отзывает — только читает куку и резолвит её в человека. Кука host-only на
# gendsgn.ru с path="/" (tradein-mvp/backend/app/api/v1/auth.py:173-181),
# поэтому браузер шлёт её на оба продукта одного домена.
#
# Режим — ТРЁХЗНАЧНЫЙ, а не булев флаг, и это сделано ради последнего PR эпика:
# legacy (ДЕФОЛТ) — сегодняшнее поведение бит-в-бит: кука не читается вовсе,
# личность берётся из X-Authenticated-User (Caddy basic_auth);
# engine БД `auth` не создаётся, соединение не открывается,
# отсутствие AUTH_* в окружении не роняет старт;
# dual — сначала кука общего реестра, при её отсутствии/сбое реестра
# деградация на легаси-заголовок (переходный режим: popup
# Caddy ещё стоит и прикрывает заголовок от подделки);
# db_only — легаси-ветка НЕДОСТИЖИМА: нет валидной сессии → 401, даже
# если X-Authenticated-User присутствует.
#
# Почему именно так, а не `AUTH_SESSION_ENABLED=true/false`. В dual-режиме сбой
# реестра (или просто отсутствие куки) уводит запрос на trusted-header. Пока
# popup стоит, это безопасно: заголовок на `/api/*` перезаписывает Caddy из
# basic_auth (Caddyfile:178-182), клиент подставить его не может. Ровно в тот
# момент, когда последний PR эпика снимет `basic_auth` + `header_up`, заголовок
# станет полностью клиентским — и та же деградация превратится в ПОЛНЫЙ обход
# аутентификации (`curl -H 'X-Authenticated-User: admin'`). Булев флаг оставлял бы
# это на память мейнтейнера («не забыть выпилить фолбэк»); режим делает переход
# сменой ОДНОГО значения (`AUTH_MODE=db_only`), а недостижимость легаси-ветки в
# нём закреплена тестами (tests/test_auth_session_guard.py, секция db_only).
# Зеркало «Меры»: tradein-mvp/backend/app/core/config.py:91 (`auth_mode`); там
# значений два — легаси-режима у неё уже нет, она на реестре с #2552.
#
# ⚠️ ДЕФОЛТ `legacy` — ЧАСТЬ КОНТРАКТА PR, А НЕ ЗАГЛУШКА: после мержа прод обязан
# работать ровно как сегодня (popup Caddy снимается последним PR эпика).
# Читатели режима: `app.main.rbac_guard` (какой источник личности и есть ли
# фолбэк), `app.services.auth_session.resolve_session_token` и
# `app.core.auth_db.require_auth_db_configured` — через производное свойство
# `auth_session_enabled` ниже.
#
# Включение на проде = одна переменная: AUTH_DB_PASSWORD в backend/.env.runtime
# уже есть (её пишет ops и читает .forgejo/workflows/deploy.yml:381-386, чтобы
# сделать ALTER ROLE auth_app), остальные части DSN имеют прод-дефолты.
# ENV: AUTH_MODE.
auth_mode: Literal["legacy", "dual", "db_only"] = "legacy"
# DSN БД `auth` целиком. Пусто по умолчанию — задавать руками не обязательно:
# см. `resolved_auth_database_url` ниже, при пустом значении DSN собирается из
# AUTH_DB_PASSWORD + частей. Явное значение, если оно есть, выигрывает всегда
# (аварийный обход: другой хост, sslmode, байпас пула). ENV: AUTH_DATABASE_URL.
auth_database_url: str = ""
# Пароль роли auth_app. Живёт в ОДНОМ месте — этой переменной: требовать вдобавок
# целиковый AUTH_DATABASE_URL значило бы держать один секрет в двух местах
# (сменили пароль роли, забыли переписать DSN → вход ложится молча и целиком).
#
# SecretStr, а не str как у соседних секретов файла: `repr(settings)` и
# `settings.model_dump()` печатают обычные str-поля ДОСЛОВНО. Сегодня их никто не
# рендерит, но появиться такой рендер может тихо — с SecretStr он напечатает
# `SecretStr('**********')`. Значение достаётся ровно в одном месте —
# `.get_secret_value()` в резолвере ниже. Соседи (openai_api_key, dadata_api_secret,
# database_url) остались str — это предсуществующее положение, а не «там безопасно».
# ENV: AUTH_DB_PASSWORD.
auth_db_password: SecretStr = SecretStr("")
# Остальные части — с дефолтами, верными для ЭТОГО стека (см. константы выше и
# разбор ловушки хоста). Переопределяются через ENV для локального запуска (напр.
# AUTH_DB_HOST=localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
auth_db_host: str = _AUTH_DB_DEFAULT_HOST
auth_db_port: int = _AUTH_DB_DEFAULT_PORT
auth_db_name: str = _AUTH_DB_DEFAULT_NAME
auth_db_user: str = _AUTH_DB_DEFAULT_USER
# Имя cookie сессии. ОБЯЗАНО совпадать с тем, которым пользуется «Мера»
# (tradein-mvp/backend/app/core/config.py:84-86) — иначе браузер шлёт куку, а
# «Птица» её не узнаёт и молча остаётся без сессии.
#
# ⚠️ Имя ИСТОРИЧЕСКОЕ: оно родилось в trade-in до того, как реестр стал общим, и
# «tradein_» в нём теперь ни о чём не говорит. Переименование разлогинивает ВСЕХ
# и СРАЗУ в обоих продуктах (старую куку никто больше не читает), поэтому меняется
# только отдельным решением — синхронно в обоих стеках и с обдуманным моментом.
# ENV: SESSION_COOKIE_NAME.
session_cookie_name: str = "tradein_session"
# TTL сессии в часах (720 = 30 дней) — тот же дефолт, что у «Меры»
# (tradein-mvp/backend/app/core/config.py:88). «Птица» сессии не выдаёт, поэтому
# значение используется ЕДИНСТВЕННЫМ образом: на сколько sliding-refresh отодвигает
# expires_at (app/services/auth_session.py). Держать его РАВНЫМ значению «Меры»
# обязательно — иначе срок жизни сессии начнёт зависеть от того, в каком продукте
# человек кликнул последним. ENV: SESSION_TTL_HOURS.
session_ttl_hours: int = 720
@field_validator("auth_mode", mode="before")
@classmethod
def _blank_auth_mode_means_legacy(cls, value: object) -> object:
"""`AUTH_MODE=` (пустая строка) → `legacy`, а не ValidationError на импорте.
Та же ловушка, что у `AUTH_DB_PORT` ниже: `settings = Settings()` выполняется на
уровне модуля, поэтому невалидное значение роняет ИМПОРТ конфига и уводит
контейнер в restart-loop. Сценарий тот же ops копирует блок AUTH_* в
.env.runtime и заполняет только пароль. Пустое значение обязано означать
«оставили как было», то есть сегодняшнее поведение.
Регистр и обрамляющие пробелы нормализуются: `AUTH_MODE=DB_ONLY ` очевидная
опечатка со смыслом, а не запрос на падение. Непустой мусор (`AUTH_MODE=off`)
по-прежнему валится, и правильно: молча трактовать его как `legacy` значило бы
тихо оставить продукт на trusted-header после снятия popup'а.
"""
if isinstance(value, str):
normalized = value.strip().lower()
return normalized or "legacy"
return value
@property
def auth_session_enabled(self) -> bool:
"""Читает ли «Птица» сессионную куку общего реестра (то есть режим не `legacy`).
Производное от `auth_mode`, а не отдельное поле: два независимых переключателя
рано или поздно разъезжаются, и получилось бы состояние «куку читаем, но режим
легаси» (или наоборот), которого нет ни в одном настоящем сценарии.
Держит инвариант «`legacy` = ни одного коннекта к реестру»: по этому свойству
закорачиваются `app.services.auth_session.resolve_session_token` и
`app.core.auth_db.require_auth_db_configured`. Разница между `dual` и `db_only`
свойству не видна и не должна быть она касается только фолбэка на
легаси-заголовок и живёт в `app.main.rbac_guard`.
"""
return self.auth_mode != "legacy"
@field_validator("auth_db_port", mode="before")
@classmethod
def _blank_auth_db_port_means_default(cls, value: object) -> object:
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
Симметрия с host/name/user, у которых пустое значение переменной падает
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
`settings = Settings()` выполняется на уровне модуля то есть `AUTH_DB_PORT=`
в .env.runtime роняло бы ValidationError на импорте конфига и уводило контейнер
в restart-loop. Причём В ЛЮБОМ режиме, включая дефолтный (флаг выключен), где к
БД `auth` не идёт ни одного обращения ровно тот инвариант «дефолт не трогаем»,
который держит весь этот PR.
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
заполняет только пароль остальные строки остаются пустыми намеренно.
`mode="before"` потому что вмешаться надо ДО приведения к int. Непустой мусор
(`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно: это опечатка со смыслом,
а не «оставил пустым».
"""
if isinstance(value, str) and not value.strip():
return _AUTH_DB_DEFAULT_PORT
return value
@property
def resolved_auth_database_url(self) -> str:
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
Приоритет:
1. `AUTH_DATABASE_URL`, если задан выигрывает всегда.
2. Иначе, если задан `AUTH_DB_PASSWORD` DSN собирается из частей.
3. Иначе пустая строка, то есть «не сконфигурировано». Это НЕ ошибка сама
по себе: при `AUTH_MODE=legacy` (дефолт) сюда не заходит никто.
Ошибку явную, а не тихий фолбэк поднимает `app.core.auth_db`, и только
когда реестр реально понадобился.
Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
исключений, не отдавать наружу (`/health`, `/docs`, метрики).
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`, `#`,
`%`) внутри пароля иначе порвал бы URL по своей грамматике `@` сдвинул бы
границу host, `/` открыл бы path. Разбор дал бы либо ошибку, либо, что хуже,
МОЛЧА другой хост/базу. По той же причине экранируется имя пользователя.
А вот имя БД и хост НЕ экранируются, и это не забывчивость: SQLAlchemy
раскодирует обратно только userinfo (user/password), а path отдаёт как есть.
Прогони мы имя БД через `quote`, в сервер уехало бы литеральное `c%2Fd` вместо
`c/d`. Хосту %-кодирование тоже только мешает оно поломало бы IPv6-скобки.
"""
explicit = self.auth_database_url.strip()
if explicit:
return explicit
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
# ведущий/хвостовой пробел может быть частью настоящего пароля.
password = self.auth_db_password.get_secret_value()
if not password.strip():
return ""
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
secret = quote(password, safe="")
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
port = self.auth_db_port
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
# Схема — ровно та же, что у продуктового database_url (psycopg v3;
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
# зависимостях нет).
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
settings = Settings()

View file

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

View file

@ -3,11 +3,14 @@
import logging
import os
import re
import threading
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import sentry_sdk
from fastapi import FastAPI, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from sentry_sdk.integrations.celery import CeleryIntegration
@ -41,10 +44,12 @@ from app.api.v1 import (
trade_in,
users,
)
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__)
@ -70,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,
@ -77,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(),
@ -97,6 +113,18 @@ if settings.glitchtip_dsn:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Эпик «единый вход», fail-fast: AUTH_MODE=dual|db_only обязан иметь РАБОЧИЙ
# реестр — проверяется не только разбор DSN, но и живое соединение (`SELECT 1`,
# app/core/auth_db.py). Не соединились → контейнер НЕ стартует. Режим `legacy`
# (ДЕФОЛТ) → no-op: ни проверки DSN, ни создания engine, ни коннекта.
#
# Почему именно на старте, а не «разберёмся в рантайме»: неверный пароль, опечатка
# в хосте, не созданная БД `auth` иначе ловились бы `except`'ом вокруг резолва
# сессии в rbac_guard, и сломанная конфигурация выглядела бы как «ни у кого нет
# сессии» — СУТКАМИ, потому что продуктовая БД жива, приложение отвечает 200, а
# сигнал остаётся только в логах. Дешевле не стартовать: деплой падает сразу и
# громко.
auth_db.require_auth_db_configured()
yield
@ -122,10 +150,212 @@ app.middleware("http")(audit_log_middleware)
# 3) /api/v1/admin/* — только role=admin, иначе 403.
# Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки —
# X-Authenticated-User там просто не приходит из Caddy.
#
# Эпик «единый вход»: к правилу 1 добавляется ПЕРВЫЙ источник личности —
# сессионная кука общего реестра (БД `auth`). Выдаёт её единственная форма входа, у
# «Меры» (/trade-in/login); «Птица» сессии только читает. Кука host-only на
# gendsgn.ru с path="/" → браузер шлёт её и сюда. Порядок: кука → легаси-заголовок.
# Дальше — ВСЁ как раньше: роль из auth/roles.yaml, admin-гейт по _ADMIN_API_RE.
# Реестр отвечает на вопрос «кто ты», roles.yaml — «что тебе можно»; продуктовые
# роли реестра (auth.users.role) в «Птицу» намеренно не протаскиваются.
#
# ⚠️ AUTH_MODE=legacy ПО УМОЛЧАНИЮ — popup Caddy basic_auth ещё стоит и снимается
# ПОСЛЕДНИМ PR эпика. Пока режим legacy, этот файл ведёт себя бит-в-бит как до эпика:
# кука не читается, БД `auth` не открывается. `dual` — переходный режим (кука, при её
# отсутствии/сбое реестра фолбэк на заголовок), `db_only` — фолбэка нет вовсе.
#
# ⚠️ ДОЛГ, КОТОРЫЙ ОБЯЗАН БЫТЬ ЗАКРЫТ ДО СНЯТИЯ POPUP'А (не решается этим PR).
# Guard проверяет ровно две вещи: есть ли username в auth/roles.yaml (get_role) и
# admin-гейт по _ADMIN_API_RE. Списки `paths`/`deny` из roles.yaml на бэкенде НЕ
# применяются — это зафиксировано в самом auth/roles.yaml:33-35 («path-level
# enforcement делает frontend RouteGuard»). Следствие: в момент включения режима
# «Птицу» получает КАЖДЫЙ аккаунт реестра, чей username совпадает с записью в
# roles.yaml, — включая роль `expired` (user2: paths: [], deny: "/**"), которую
# сегодня останавливает только фронт. Это не регрессия (те же люди сегодня в
# caddy/users.caddy.snippet и добираются туда же через basic_auth), но эпик делает
# её несущей: (а) до снятия popup'а отзыв доступа имеет ДВА рубильника —
# caddy-snippet и access_state в реестре, их надо держать синхронными; (б) после
# снятия roles.yaml остаётся ЕДИНСТВЕННЫМ гейтом, и `expired` в нём станет чисто
# фронтовой фикцией. Перед включением: сверить `auth.users.username` на проде с
# `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать
# `expired` как вводящий в заблуждение.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит `X-Authenticated-User` в ASGI-scope — ПЕРЕЗАПИСЫВАЯ, а не дополняя.
🔴 Перезапись, а не «поставить, если отсутствует» это требование безопасности,
а не стилистика. В бэкенде «Птицы» ОДИННАДЦАТЬ мест читают этот заголовок НАПРЯМУЮ,
мимо guard'а, и решают по нему, кто автор/кому принадлежат данные:
app/core/audit_middleware.py:169 атрибуция строки аудита;
app/api/v1/me.py:30 чей scope отдать (роль + фильтры);
app/api/v1/insights.py:74/124/138 created_by + _require_user (POST/PUT/DELETE);
app/api/v1/own_projects.py:69/115/131 created_by + _require_user (POST/PUT/DELETE);
app/api/v1/parcels.py:1481 GET /{cad_num}/forecast;
app/api/v1/parcels.py:1902 POST /{cad_num}/analyze (created_by рана,
parcels.py:4212, и 3-й аргумент forecast_site_finder_report.delay, :4226);
сам rbac_guard ниже легаси-ветка.
Ни одно из них не знает про сессию: для них истина сырой заголовок. Оставь мы
skip-if-present клиент с ВАЛИДНОЙ кукой прошёл бы guard как он сам, а во все эти
места уехал бы его собственный подставленный `X-Authenticated-User: <кто угодно>`
(Caddy шлёт этот заголовок на каждый прод-запрос, так что «просто добавить» его
было бы некуда). Ровно этот баг ловили у «Меры» #2552 post-review, CRITICAL.
Резолвнутая сессия ОБЯЗАНА быть единственным источником личности.
Механизм: `request.scope` один и тот же dict, прокинутый ПО ССЫЛКЕ через весь
ASGI-стек (Starlette не копирует scope между слоями). Мутация здесь видна:
всей downstream-цепочке мы мутируем ДО вызова call_next();
audit-middleware он ВНУТРЕННИЙ относительно rbac_guard (см. комментарий у
app.middleware("http")(audit_log_middleware) выше: LIFO-регистрация даёт
порядок rbac_guard audit router), т.е. его Request строится уже после
мутации. У «Меры» этот слой, наоборот, внешний, и там мутация до него
доезжает только потому, что читается ПОСЛЕ call_next.
Имена заголовков в ASGI по спеке всегда lowercase bytes, и uvicorn/TestClient
её соблюдают. Фильтр всё равно нормализует ключ сам (`k.lower()`), а не полагается
на спеку: попади в scope запись `b"X-Authenticated-User"` (другой ASGI-сервер,
самодельный слой, тест-харнесс) точное сравнение оставило бы её в списке рядом с
нашей. Читатели при этом видели бы правильное значение (`Headers.get` лоуэркейсит
искомый ключ, но не хранимый, так что смешанный регистр не матчится никогда), то
есть дыры нет но состояние «две записи с одним именем» в scope не должно
существовать: оно ложное по построению и ломает любой обход списка глазами.
`errors="replace"` в encode: латиницей логины реестра не ограничены, а падать
UnicodeEncodeError в auth-пути нельзя.
NB: `request.headers` САМОГО этого Request уже закеширован (мы читали cookies) и
останется старым. Это не мешает: в session-ветке guard больше не читает заголовок,
а нижележащие слои строят свой Request поверх обновлённого scope.
"""
request.scope["headers"] = [
(k, v) for k, v in request.scope.get("headers", []) if k.lower() != b"x-authenticated-user"
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
# Троттлинг алерта «реестр не отвечает». Резолв сессии идёт на КАЖДОМ non-public
# запросе с кукой, а `logger.exception` уровня ERROR уезжает событием в GlitchTip
# (LoggingIntegration event_level=ERROR, см. sentry_sdk.init выше) — то есть лежащий
# реестр давал бы поток событий, пропорциональный трафику: квота/rate-limit выгорают
# за минуты, и настоящие ошибки этого же периода теряются. Полный traceback печатаем
# не чаще раза в минуту (с числом подавленных за окно), остальное — WARNING без
# exc_info, чтобы факт продолжающегося сбоя всё равно был виден в логах.
# Лок нужен по-настоящему: функция исполняется в threadpool'е, то есть параллельно.
_REGISTRY_FAILURE_ALERT_INTERVAL_S = 60.0
_REGISTRY_FAILURE_LOCK = threading.Lock()
_registry_failure_last_alert = 0.0
_registry_failure_suppressed = 0
def _reset_registry_failure_throttle() -> None:
"""Сбрасывает окно троттлинга. Для тестов: состояние модульное и живёт между ними."""
global _registry_failure_last_alert, _registry_failure_suppressed
with _REGISTRY_FAILURE_LOCK:
_registry_failure_last_alert = 0.0
_registry_failure_suppressed = 0
def _log_registry_failure(path: str) -> None:
"""Логирует сбой резолва: раз в окно — ERROR с traceback, иначе WARNING.
Зовётся ТОЛЬКО из `except`-блока: `logger.exception` берёт traceback из текущего
sys.exc_info().
"""
global _registry_failure_last_alert, _registry_failure_suppressed
now = time.monotonic()
with _REGISTRY_FAILURE_LOCK:
alert = (now - _registry_failure_last_alert) >= _REGISTRY_FAILURE_ALERT_INTERVAL_S
if alert:
suppressed = _registry_failure_suppressed
_registry_failure_last_alert = now
_registry_failure_suppressed = 0
else:
suppressed = 0
_registry_failure_suppressed += 1
if alert:
logger.exception(
"RBAC: резолв сессии не удался на %s — эти запросы обслуживаются по "
"легаси-пути (Caddy basic_auth + X-Authenticated-User); подавлено таких же "
"за предыдущее окно: %d",
path,
suppressed,
)
else:
logger.warning(
"RBAC: резолв сессии не удался на %s (traceback подавлен троттлингом, "
"следующий — не раньше чем через %.0f с)",
path,
_REGISTRY_FAILURE_ALERT_INTERVAL_S,
)
def _resolve_session_username(token: str | None, path: str) -> str | None:
"""Логин из сессионной куки, либо None, если личность по куке не установлена.
🔴 СИНХРОННАЯ и вызывается ТОЛЬКО через `run_in_threadpool` (см. rbac_guard):
внутри psycopg-I/O (checkout из пула + SELECT, раз в 5 минут ещё UPDATE и
commit). Позови её напрямую из корутины guard'а — и весь API «Птицы»
сериализуется за один round-trip к БД `auth` на каждый запрос, а недоступный
реестр (или исчерпанный пул) заморозит event loop целиком, включая /health. Ровно
этот инцидент уже был на соседнем middleware #1202, см. комментарий в
app/core/audit_middleware.py:175-181, там он и починен через `run_in_threadpool`.
Токен принимается ГОТОВЫМ (а не `Request`) именно поэтому: разбор Cookie-заголовка
дёшев и делается на loop'е, в поток уезжает только строка.
None означает ровно одно «личность по куке не установлена», и вызывающий обязан
трактовать это одинаково во всех трёх случаях: куки нет, кука невалидна (нет
строки / истекла / access_state не active), резолв УПАЛ.
Поведение при сбое БД `auth` (осознанный выбор, а не «поймали и забыли»): логируем
ERROR с traceback он уезжает событием в GlitchTip (LoggingIntegration
event_level=ERROR, см. sentry_sdk.init выше), т.е. это алерт, а не строчка, которую
никто не увидит (частота ограничена окном, `_log_registry_failure`), и в режиме
`dual` деградируем к легаси-ветке, то есть к сегодняшнему поведению: Caddy
basic_auth + X-Authenticated-User. В режиме `db_only` деградации нет: guard
отвечает 401.
Почему НЕ 503/500. Пока идёт переходный период, popup basic_auth стоит перед
бэкендом, и легаси-ветка защищена ровно тем же, чем защищён весь продукт сегодня,
множество людей, способных вообще достучаться, не расширяется. Отдавать же 503
значит класть «Птицу» целиком из-за проблемы, которую basic_auth уже покрывает
(отозванный пароль роли auth_app, пересозданная БД `auth`, исчерпанный пул её
engine всё это не мешает продуктовой БД gendesign работать).
Почему это не «тихий фолбэк на легаси». Опасный сценарий не «реестр упал», а
«реестр не сконфигурирован»: тогда права раздавались бы из roles.yaml в обход
реестра (включая аккаунты с access_state disabled/trial_expired) бессрочно и молча.
Этот сценарий сюда НЕ доходит: конфигурацию проверяет lifespan, причём НЕ на глазок
`require_auth_db_configured` открывает соединение и делает `SELECT 1`, так что мимо
него не проходят ни пустой/битый DSN, ни неверный пароль, ни опечатка в хосте, ни
отозванная роль (app/core/auth_db.py). Здесь остаётся только второй рубеж реестр,
отвалившийся ПОСЛЕ успешного старта.
Отдельно про отзыв доступа: пароли Caddy basic_auth (caddy/users.caddy.snippet)
и `auth.users.access_state` РАЗНЫЕ списки. Человек, которому в реестре поставили
disabled/trial_expired, свой basic_auth-пароль не теряет, поэтому на время
недоступности реестра деградация возвращает его в строй. То есть отзыв тут не
«строже сегодняшнего», а откатывается к состоянию ДО отзыва при включении режима
caddy-snippet надо прополоть под список активных аккаунтов реестра.
Когда последний PR эпика снимет popup, эта деградация обязана уйти вместе с ним:
без basic_auth впереди фолбэк на легаси-заголовок превращается в дыру заголовок
станет полностью клиентским. Механика перехода уже готова: `AUTH_MODE=db_only`
(см. app/core/config.py), в нём легаси-ветка недостижима и этот возврат None
означает 401, а не «попробуем заголовок».
"""
if not token:
# Нет куки — ни одного обращения к БД `auth`. Это весь сегодняшний трафик.
return None
try:
session_user = resolve_session_token(token)
except Exception:
_log_registry_failure(path)
return None
if session_user is None:
return None
return session_user.username
@app.middleware("http")
async def rbac_guard(
request: Request,
@ -134,6 +364,17 @@ async def rbac_guard(
# Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User.
# СТРОГО gated на settings.testing (default False) — прод RBAC не затронут.
# RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware).
#
# ⚠️ Он ОТКЛЮЧАЕТ ВЕСЬ guard целиком, включая session-ветку ниже, — и это сказано
# здесь явно, чтобы не выглядело недосмотром. Следствие для тестов: сессионный путь
# НЕЛЬЗЯ проверять запросом к настоящему `app` через TestClient (conftest ставит
# settings.testing=True глобально, guard просто не отработает, тест «прошёл бы» ни о
# чём). Он и проверяется иначе: tests/test_auth_session_guard.py зовёт ЭТУ САМУЮ
# функцию напрямую, сняв settings.testing через monkeypatch, — то есть прод-код, а
# не копию. Копия guard'а в tests/test_rbac.py про куку намеренно НЕ знает и
# покрывает только режим legacy (там об этом написано). Сдвигать session-ветку ВЫШЕ
# bypass'а нельзя: получился бы полуработающий guard (личность резолвится, а 401/403
# не применяются) — состояние, которого нет ни в одном настоящем режиме.
if settings.testing:
return await call_next(request)
@ -141,22 +382,67 @@ async def rbac_guard(
if path in _PUBLIC_PATHS:
return await call_next(request)
username = request.headers.get("X-Authenticated-User")
if not username:
# Любой non-public path без auth-header → 401. Локальный curl мимо Caddy
# или прокси-фронт без header_up. 401 точнее чем 403 — "сначала
# аутентифицируйся".
# Внешний `if` по режиму — не дубль проверки внутри resolve_session_token(), а
# гарантия инварианта «legacy = поведение не меняется ни на байт»: в нём не
# трогается даже request.cookies (разбор Cookie-заголовка).
token = (
request.cookies.get(settings.session_cookie_name) if settings.auth_session_enabled else None
)
# 🔴 Резолв — В THREADPOOL. Внутри синхронный psycopg-I/O, а мы в корутине: прямой
# вызов блокировал бы event loop на каждом запросе с кукой (инцидент #1202, тот же
# класс, что чинили в app/core/audit_middleware.py:175-183). `if token` перед
# хопом — не микрооптимизация: без куки резолвить нечего, и весь сегодняшний
# трафик не платит ни за поток, ни за коннект.
session_username = (
await run_in_threadpool(_resolve_session_username, token, path) if token else None
)
if session_username is not None:
username = session_username
# 🔴 До call_next и до всего остального: личность из сессии обязана вытеснить
# клиентский заголовок для одиннадцати прямых читателей (см. функцию).
_propagate_authenticated_user(request, username)
elif settings.auth_mode == "db_only":
# Легаси-ветка ОТКЛЮЧЕНА: нет валидной сессии → отказ, даже если
# X-Authenticated-User присутствует. Это конечное состояние эпика — режим
# включается тем же PR, который снимает `basic_auth` + `header_up` из Caddy и
# тем самым делает заголовок полностью клиентским. Отдельный текст ответа:
# «no authenticated user» ниже говорит про basic_auth, которого в этот момент
# уже нет.
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
content={"detail": "valid session required"},
)
else:
# ---- легаси trusted-header путь — БИТ-В-БИТ как до эпика ----
header_user = request.headers.get("X-Authenticated-User")
if not header_user:
# Любой non-public path без auth-header → 401. Локальный curl мимо Caddy
# или прокси-фронт без header_up. 401 точнее чем 403 — "сначала
# аутентифицируйся".
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
username = header_user
try:
role = get_role(username)
except KeyError:
# Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ.
# Decided 2026-05-25: «человек без ролей вообще ничего не видит».
logger.warning("RBAC: unknown user %r tried %s", username, path)
if session_username is not None:
# Тот же отказ, но отдельным сообщением: «есть в реестре, нет в roles.yaml» —
# это рассинхрон двух списков (типовой при заведении нового аккаунта), а не
# подделка заголовка, и чинится он в другом месте.
logger.warning(
"RBAC: сессия резолвлена в %r, но юзера нет в auth/roles.yaml — отказ на %s",
username,
path,
)
else:
logger.warning("RBAC: unknown user %r tried %s", username, path)
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
@ -222,3 +508,24 @@ async def health() -> dict[str, str]:
"environment": settings.environment,
"version": app.version,
}
# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от
# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health
# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health {
# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на
# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor
# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по
# статусу. media_type="application/json" — Content-Type совпадает с GET;
# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы
# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload-
# заголовки (Content-Length) для HEAD, требует совпадения только заголовков
# представления (Content-Type).
# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть
# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в
# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации
# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута,
# который фронт никогда не вызывает.
@app.head("/health", include_in_schema=False)
async def health_head() -> Response:
return Response(status_code=200, media_type="application/json")

View file

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

View file

@ -0,0 +1,256 @@
"""Резолв сессионной куки общего реестра (БД `auth`) — сторона «Птицы».
Эпик «единый вход»: вместо браузерного popup'а Caddy basic_auth у продукта одна
нейтральная форма входа. Живёт она у «Меры» (`/trade-in/login`): та проверяет
пароль, пишет строку в `auth.sessions` и ставит куку host-only на gendsgn.ru с
`path="/"` поэтому браузер шлёт её и на `/site-finder/**` тоже.
«Птица» эту куку ТОЛЬКО ЧИТАЕТ. Здесь нет и не должно появиться `create_session` /
`revoke_session`: выдача и отзыв исключительная ответственность единственной
формы входа, второй эмитент сессий означал бы два места, где решается «кого
пускать», и расходящиеся правила блокировки.
Что модуль отдаёт вызывающему: `resolve_session_token(token)` `SessionUser`
(username + состояние доступа) либо None. Что делать с username дальше дело
guard'а: авторизация «Птицы» (какие пути кому видны) по-прежнему живёт в
`auth/roles.yaml` (`app.core.auth.get_role`), продуктовые роли реестра
(`auth.users.role` admin/manager/employee, миграция data/sql/auth/004) сюда
намеренно НЕ протаскиваются: это другая ролевая модель, и её отображение на
roles.yaml отдельное решение стадии 2, а не побочный эффект резолва сессии.
Токены опаковые (`secrets.token_urlsafe` на стороне «Меры») не JWT, не подписаны:
валидность проверяется исключительно наличием строки в БД + `expires_at` +
состоянием доступа юзера. Никакого разделяемого секрета между стеками для этого
не нужно только доступ к одной БД.
Имена таблиц (`users`, `sessions`) и колонок литералы из data/sql/auth/001 и 004;
снаружи в SQL-строку не попадает ничего, значения идут bind-параметрами.
Зеркало по подходу: tradein-mvp/backend/app/services/auth_session.py («Мера»). Там
модуль дополнительно умеет две схемы (переходный `identity_store`) и выдачу сессий
здесь этого нет за ненадобностью.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core import auth_db
from app.core.config import settings
logger = logging.getLogger(__name__)
# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в 5
# минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним UPDATE
# (guard резолвит сессию на КАЖДЫЙ non-public запрос). Значение и механика — те же,
# что у «Меры» (tradein-mvp/.../auth_session.py:51): сессия общая, и продлевать её
# два продукта обязаны одинаково.
_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5)
class AccessState(StrEnum):
"""Состояние доступа аккаунта — значения дословно из `auth.users.access_state`.
CHECK-констрейнт `users_access_state_ck`, миграция data/sql/auth/004; семантика
оттуда же (решение владельца от 2026-07-31):
active доступ есть;
trial_expired пароль верный, но пробный период истёк;
disabled доступ закрыт владельцем.
Для «Птицы» все три состояния делятся надвое (`can_sign_in`): отдельный экран
«пробный доступ закончился» сюжет формы входа, то есть «Меры»; сюда приходит
уже вошедший человек, и всё, что не `active`, для него значит одно сессии нет.
"""
ACTIVE = "active"
TRIAL_EXPIRED = "trial_expired"
DISABLED = "disabled"
@property
def can_sign_in(self) -> bool:
"""True только для `active` — единственная проверка «пускать ли».
Вынесена в свойство, чтобы вызывающий не писал `state == "active"`: добавится
четвёртое состояние оно по умолчанию окажется «не пускать», а не «пускать,
потому что не disabled».
"""
return self is AccessState.ACTIVE
def to_access_state(value: object) -> AccessState:
"""Приводит значение колонки `users.access_state` к `AccessState`.
Fail-closed: неизвестная строка, NULL и любой неожиданный тип `disabled` +
WARNING. Обратный выбор (пускать всё, что не `disabled`) означал бы, что новое
состояние, добавленное миграцией раньше кода, молча раздаёт доступ а миграции
БД `auth` применяются деплоем «Птицы» (.forgejo/workflows/deploy.yml), то есть
опередить код они могут запросто.
"""
if isinstance(value, str):
try:
return AccessState(value)
except ValueError:
logger.warning(
"auth_session: неизвестное состояние доступа %r → трактую как disabled", value
)
return AccessState.DISABLED
logger.warning(
"auth_session: состояние доступа %r неожиданного типа %s → трактую как disabled",
value,
type(value).__name__,
)
return AccessState.DISABLED
@dataclass(frozen=True, slots=True)
class SessionUser:
"""Кто стоит за валидной сессионной кукой.
Attributes:
username: логин из реестра. Именно он, а не значение куки, дальше едет в
RBAC «Птицы» (`app.core.auth.get_role`).
access_state: всегда `AccessState.ACTIVE` не-active сюда не доходит
(см. `get_session_user`). Поле оставлено явным, чтобы состояние доступа
во всём коде называлось и выражалось одинаково, а не превращалось в
неявное «раз объект вернулся, значит active».
"""
username: str
access_state: AccessState
def get_session_user(db: Session, token: str) -> SessionUser | None:
"""Резолвит сессионный токен в пользователя, или None если сессия невалидна.
Невалидна = не найдена / истекла / состояние доступа юзера не `active`.
Состояние доступа: пропускается ТОЛЬКО `AccessState.ACTIVE`. Любое другое
(`disabled`, `trial_expired`, а также нераспознанное `to_access_state`
fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной
НЕМЕДЛЕННО, не дожидаясь `expires_at`. Иначе заблокированный человек продолжал
бы работать до истечения TTL (до 30 дней), а sliding-refresh продлевал бы ему
сессию бесконечно то есть блокировка в реестре не блокировала бы ничего.
Sliding refresh: если с последнего `last_seen_at` прошло >= 5 минут продлевает
`last_seen_at`/`expires_at` ОДНИМ UPDATE (ровно как «Мера»: тот же интервал, тот
же одиночный UPDATE обеих колонок, тот же best-effort). Продлевать обе колонки
обязательно: обновляй «Птица» только `last_seen_at`, человек, работающий весь
день в ней одной, был бы разлогинен по `expires_at` несмотря на активность.
Сбой refresh (напр. read-only реплика) логируется и НЕ мешает вернуть валидного
юзера это best-effort продление, а не часть решения «валидна ли сессия».
Принимает уже открытую сессию БД `auth` (не открывает сам) так модуль остаётся
тривиально unit-тестируемым. Обычный вызывающий берёт `resolve_session_token`.
`db` ОБЯЗАНА быть сессией БД `auth` (`app.core.auth_db.auth_session()`), а не
`app.core.db.get_db`: в продуктовой БД gendesign таблиц `users`/`sessions` нет.
Исключения БД наружу НЕ глушатся (кроме best-effort refresh): сбой реестра
часть auth-решения, и вызывающий обязан его увидеть, чтобы закрыться, а не
трактовать как «сессии нет».
"""
if not token:
return None
row = db.execute(
text(
"""
SELECT s.expires_at, s.last_seen_at, u.username, u.access_state
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = :token
AND s.expires_at > now()
"""
),
{"token": token},
).fetchone()
if row is None:
return None
now = datetime.now(UTC)
# Второй пояс к `AND s.expires_at > now()` в SELECT'е выше. Первый пояс — часами
# БД, и это принципиально: строку продлевает UPDATE ниже, где `expires_at =
# now() + interval` считает СЕРВЕР. Реши мы срок годности только часами процесса
# (`datetime.now(UTC)`), отставание этих часов давало бы не «сессия проживёт на
# дельту дольше», а НЕОБРАТИМОЕ воскрешение: строку, которую БД уже считает
# мёртвой, Python пропустил бы, тут же сработал бы sliding-refresh и отодвинул
# expires_at на полный TTL от серверного now(). Секунда расхождения → +30 дней.
# Обе стороны сравнения обязаны брать время из одного источника.
#
# Проверку на None оставляем первой: `expires_at` объявлен NOT NULL
# (data/sql/auth/001), но если колонку когда-нибудь ослабят, это дешевле
# разбирательства, почему сравнение с None упало TypeError'ом в auth-пути.
if row.expires_at is None or row.expires_at <= now:
return None
access_state = to_access_state(row.access_state)
if not access_state.can_sign_in:
return None
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
try:
db.execute(
text(
"""
UPDATE sessions
SET last_seen_at = now(),
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
WHERE token = :token
"""
),
{"ttl_hours": settings.session_ttl_hours, "token": token},
)
db.commit()
except Exception:
# Без username в сообщении: строка лога — не место для связки
# «кто именно» + «в какой момент», а разбор всё равно идёт по времени.
logger.warning("auth_session: sliding refresh failed", exc_info=True)
try:
db.rollback()
except Exception:
# Причина сбоя UPDATE'а может быть оборванным соединением — тогда и
# rollback бросит. Без этого except «best-effort продление» переставало
# бы быть best-effort: валидный юзер, чью сессию не удалось продлить,
# получал бы не доступ, а исключение наружу (и в guard'е — деградацию
# на легаси-заголовок, а в db_only — отказ).
logger.warning("auth_session: rollback after failed refresh failed", exc_info=True)
return SessionUser(username=row.username, access_state=access_state)
def resolve_session_token(token: str | None) -> SessionUser | None:
"""Резолвит токен сессионной куки, сам открывая соединение с БД `auth`.
Точка входа для `rbac_guard` (`app/main.py`), который зовёт её в threadpool
внутри синхронный psycopg-I/O, а guard живёт на event loop'е. Возвращает None,
если сессии нет или она недействительна.
Режим `legacy` (`AUTH_MODE=legacy`, ДЕФОЛТ) None СРАЗУ, без единого
обращения к БД: инвариант «выключенный флаг = ни одного коннекта к реестру»
держится этим модулем, а не соглашением с вызывающим. Тихий None здесь безопасен,
потому что направлен в сторону fail-closed он означает ровно «session-auth не
используется», то есть сегодняшнее поведение (Caddy basic_auth + trusted-header),
и никому ничего не открывает.
Исключения НЕ глушатся ни `AuthDatabaseNotConfiguredError` (флаг включён, DSN
пуст/битый), ни ошибки соединения. Решение «что делать со сломанным реестром»
принимает guard, и оно неочевидно: молча откатиться на trusted-header значит
раздавать права из roles.yaml в обход реестра, включая заблокированные аккаунты.
Прятать такое внутри резолвера нельзя.
Raises:
AuthDatabaseNotConfiguredError: флаг включён, а DSN БД `auth` пуст или не
разобрался (см. `app.core.auth_db`).
"""
if not settings.auth_session_enabled:
return None
if not token:
return None
with auth_db.auth_session() as db:
return get_session_user(db, token)

View file

@ -30,7 +30,12 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkServerError
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkRateLimitError,
NspdBulkServerError,
NspdBulkWafError,
)
from app.services.cadastre.grid_geometry import generate_grid_click_points, quarter_bbox_3857
logger = logging.getLogger(__name__)
@ -182,6 +187,13 @@ async def harvest_quarter(
try:
cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id)
result.snapshot_requests += 1
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: бан IP / исчерпанные ретраи — НЕ «этот cat не дошёл».
# Контракт harvest_quarter (Raises:) обещает пробросить их наверх,
# а голый except ниже их глотал: прогон доходил до status='done'
# с частичными данными. Прод-замер 13.08: 23 job'а, 50 WAF-блоков,
# 0 упавших — то есть бан ни разу не остановил сбор.
raise
except Exception as e:
logger.warning(
"harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s",
@ -279,6 +291,9 @@ async def harvest_quarter(
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
raise
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
@ -399,6 +414,18 @@ async def _grid_walk_category(
requests += 1
server_errors += 1
continue
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464-A: 403 WAF — бан IP, а не «этот cell не дошёл». Продолжать
# обход значит углублять бан и дописать в БД ложный нулевой слой.
# Зеркало уже исправленных nspd_bulk_client.get_features_in_bbox_grid
# и nspd_client.get_features_in_bbox_grid (#2464-G).
logger.warning(
"_grid_walk_category: WAF/rate-limit layer=%d quarter=%s cell=%d — прерываем",
layer_id,
quarter,
idx,
)
raise
except Exception as e:
# Прочие (сетевые / parse) ошибки одного cell — тоже не валим квартал,
# но это НЕ server-side 500 → не учитываем в server_errors (иначе сеть

View file

@ -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"))],

View file

@ -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"))],

View file

@ -3,10 +3,10 @@
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
покрытие domrfobjective, глубина истории, шок-окно) в ОДИН отчётный уровень
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
покрытие domrfobjective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987.
цена известна у 12% ближних ЖК»). Наполняет слот `ReportConfidence` отчёта #987.
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x
движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только
@ -28,8 +28,10 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ,
мало сделок скоростные метрики статистически ненадёжны.
analog_count (ЖК-аналоги, = market_metrics.obj_count) high3 / medium2 / 1 low
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» ТЗ §15-пример).
domrf_coverage главный риск проекта (domrfobjective ~2.5%, см. market_metrics
docstring): низкое покрытие скрытый/будущий слой §9.3 недооценён.
domrf_coverage имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой
из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга
domrfobjective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08:
медиана 40%, среднее 31.7%. Низкое покрытие рынок и конкуренция оценены хуже.
history_months зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (2 года) / _MIN_OBS=8: короткий
ряд связь ratesales / тренды не установлены.
confounded шок-окно (is_confounded_window, PR2): ряд пересекает структурный
@ -91,9 +93,11 @@ _DEAL_COUNT_LOW: int = 15
_ANALOG_COUNT_HIGH: int = 3
_ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low
# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%).
# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог
# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства).
# domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1] (имя ключа историческое,
# см. _coverage_factor). high — покрытие плотное; low — рынок оценён по меньшинству ЖК.
# medium-порог созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6.
# NB: пороги подбирались под ожидавшиеся ~2.5% покрытия маппинга, а реальная величина
# другого порядка (медиана 40%) — их стоит пересмотреть отдельно, замером, а не на глаз.
_DOMRF_COVERAGE_HIGH: float = 0.6
_DOMRF_COVERAGE_LOW: float = 0.2
@ -252,23 +256,36 @@ _QUALITY_WORD: dict[Confidence, str] = {
def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
"""domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
"""Покрытие рынка ценами Objective ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ
(структурный §15-пример «покрытие domrfobjective 2.5%»). None low.
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
маппинга domrfobjective, но такого продьюсера НЕТ и не было: слот
`supply_layers.domrf_coverage` никто не заполняет (см. явную оговорку в
`orchestrator._summarize_supply_layers`), и значение ВСЕГДА приходит из
`analyze.market_data_coverage_pct` = `competitors_priced / competitors_total`,
то есть доля БЛИЖНИХ ЖК (3 км), у которых есть цена из Objective.
Замер на проде 13.08: 2074 анализа, min 0% · медиана 40% · среднее 31.7% ·
max 70%. Это не «~2.5% покрытия domrfobjective», как было написано здесь
раньше, другая величина другого порядка.
Ключ фактора НЕ переименован намеренно: его читает фронт
(`ForecastConfidenceBlock`, `ConfidencePanel`) как стабильный контракт.
Порог и значение не меняются правится только то, что читает человек.
None low.
"""
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
if coverage is None:
note = (
"Доля будущих проектов с известными планировками и площадями неизвестна — "
"оценка будущего предложения и конкуренции менее надёжна"
"Доля ближних ЖК с известной ценой из Objective неизвестна — "
"оценка рынка и конкуренции менее надёжна"
)
else:
pct = round(float(coverage) * 100.0, 1)
note = (
f"Известные планировки и площади есть у {pct}% будущих проектов "
f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза "
"будущего предложения и конкуренции"
f"Цена из Objective известна у {pct}% ближних ЖК "
f"({_QUALITY_WORD[level]}) — от этого зависит точность оценки "
"рынка и конкуренции"
)
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
@ -294,9 +311,7 @@ def _history_factor(history_months: int | None) -> ConfidenceFactor:
"ряде тренды и чувствительность спроса к ставке оцениваются хуже "
"(поэтому в 6.2 может остаться один сценарий вместо трёх)"
)
return ConfidenceFactor(
name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note
)
return ConfidenceFactor(name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note)
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
@ -479,7 +494,9 @@ def compute_report_confidence(
deal_count_months: окно наблюдения для deal_count (мес) добавляет «за N мес»
в ноту фактора («7 сделок за 6 мес мало»). None нота без периода.
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
domrf_coverage: доля domrfobjective [0,1] (главный sparse-риск проекта).
domrf_coverage: доля ближних ЖК с ценой из Objective [0,1]. Имя ключа
историческое про маппинг domrfobjective, продьюсера для которого
нет и не было (#2464-H, см. _coverage_factor).
history_months: глубина ряда (мес).
confounded: True, если окно ряда пересекает шок-период (PR2).
advisory: весь стек советующий cap 'medium' (по умолчанию True; почти всегда).

View file

@ -203,15 +203,23 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None
def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None:
"""Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE.
"""Покрытие рынка ценами Objective ∈ [0,1] — для фактора domrf_coverage. PURE.
Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ
per-branch НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8%
спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе,
который §15 призван флагать):
`supply_layers.domrf_coverage` уже ДОЛЯ [0,1] (0.025) берём как есть.
`analyze.market_data_coverage_pct` всегда ПРОЦЕНТ (2.5 == 2.5%) /100 доля.
Нет сигнала None (#990 → тянет в low: слой §9.3 недооценён).
Источники по приоритету (единица ЯВНАЯ per-branch НЕ угадываем по величине,
иначе настоящий sub-1% процент типа 0.8% спутался бы с долей 0.8 = 80%):
`supply_layers.domrf_coverage` ДОЛЯ [0,1] берём как есть.
`analyze.market_data_coverage_pct` ПРОЦЕНТ (40 == 40%) /100 доля.
Нет сигнала None.
#2464-H, важно для читающего: **первая ветка не исполнялась ни разу**. Слот
`supply_layers.domrf_coverage` никто не заполняет `_summarize_supply_layers`
в orchestrator это прямо оговаривает («domrf_coverage здесь НЕ выводим нет
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
величина у неё другая: не «покрытие маппинга domrfobjective ~2.5%», как
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
Порядок веток оставлен: если продьюсер появится, приоритет у него.
"""
if supply_layers is not None:
coverage = supply_layers.get("domrf_coverage")

View file

@ -559,7 +559,11 @@ class NSPDClient:
"""
# Импортируем здесь чтобы избежать circular import:
# nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain)
from app.scrapers.nspd_bulk_client import NSPDBulkClient
from app.scrapers.nspd_bulk_client import (
NSPDBulkClient,
NspdBulkServerError,
NspdBulkWafError,
)
xmin, ymin, xmax, ymax = bbox
width_m = xmax - xmin
@ -607,10 +611,45 @@ class NSPDClient:
results = await asyncio.gather(*tasks, return_exceptions=True)
features: list[NSPDFeature] = []
for r in results:
if isinstance(r, Exception):
logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r)
# #2464-G: раньше ЛЮБОЕ исключение ячейки глушилось warning'ом и обход
# возвращал []. Отказ слоя (WAF-бан IP, 5xx на всех ячейках) становился
# неотличим от честного «здесь зон нет» — на проде это 124 дампа из 669
# с territorial_zones_count=0, из них у 50 legacy-слой данные нашёл.
# Ниже — зеркало уже исправленного близнеца
# nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror).
server_errors = 0
ok_cells = 0
first_server_error: NspdBulkServerError | None = None
for idx, r in enumerate(results):
if isinstance(r, NspdBulkWafError):
# 403 WAF — бан IP. Пробрасываем немедленно: продолжать обход
# бессмысленно, а пустой результат соврал бы про отсутствие зон.
logger.warning(
"get_features_in_bbox_grid layer=%d cell=%d WAF 403 — прерываем обход: %s",
layer_id,
idx,
r,
)
raise r
if isinstance(r, NspdBulkServerError):
server_errors += 1
if first_server_error is None:
first_server_error = r
logger.debug(
"get_features_in_bbox_grid layer=%d cell=%d server error: %s",
layer_id,
idx,
r,
)
continue
if isinstance(r, Exception):
# Сетевые / parse-ошибки одной ячейки: обход не валим и НЕ
# считаем server-side, иначе сеть ложно поднимет layer_failed.
logger.warning(
"get_features_in_bbox_grid layer=%d cell=%d error: %s", layer_id, idx, r
)
continue
ok_cells += 1
for bulk_feat in r:
raw = {
"id": bulk_feat.id,
@ -618,6 +657,20 @@ class NSPDClient:
"properties": bulk_feat.properties,
}
features.append(NSPDFeature.from_raw(raw))
# Были server-side отказы И ни одна ячейка не прошла — лёг слой или
# весь NSPD. Возврат [] здесь означал бы «зон нет», хотя мы просто
# ничего не узнали. Пробрасываем, чтобы caller отличил одно от другого.
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
logger.warning(
"get_features_in_bbox_grid layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
"(%d server errors, 0 успешных ячеек) — бросаем вместо ложного пустого",
layer_id,
effective_n,
effective_n,
server_errors,
)
raise first_server_error
return features
raw_features = asyncio.run(_run_grid())
@ -679,6 +732,10 @@ class NSPDClient:
dict[layerId, list[NSPDFeature]]. Ключи все запрошенные layerId
(пустой list если слой пуст / упал). Стабильная форма для caller'а.
"""
# Локальный импорт по той же причине, что в get_features_in_bbox_grid:
# nspd_client ← nspd_bulk_client дало бы circular import на top-level.
from app.scrapers.nspd_bulk_client import NspdBulkServerError
layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
result: dict[int, list[NSPDFeature]] = {}
for layer_id in layer_ids:
@ -686,7 +743,15 @@ class NSPDClient:
feats = self.get_features_in_bbox_grid(
layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
)
except (NspdLiteError, NspdLiteWafError) as exc:
except (NspdLiteError, NspdLiteWafError, NspdBulkServerError) as exc:
# #2464-G: с этой правки grid-walk умеет бросать NspdBulkServerError
# («слой лёг целиком»). Здесь ловим его И оставляем прежнее поведение —
# пустой список на слой, — потому что именно это обещает докстрока
# («пустой list если слой пуст / упал») и на это опирается вызывающий.
# NspdBulkWafError НЕ ловим намеренно: 403 — это бан IP, продолжать
# обход остальных слоёв значит углублять бан.
# Ограничение честно: наружу отсюда «упал» и «пусто» по-прежнему
# неразличимы — у функции нет канала для флага. Отдельным заходом.
logger.warning(
"get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
layer_id,
@ -840,9 +905,19 @@ class NSPDClient:
`layers_fetched` в этом случае содержит только `('search',)`.
Raises:
NspdLiteWafError при 403/429 на любом из layer запросов caller
должен делать backoff. Partial-success НЕ возвращается; вся
операция атомарна (failure exception).
NspdLiteWafError при 403/429 на legacy-запросах (parcels/buildings)
caller должен делать backoff.
NspdBulkWafError при 403 на любой ячейке grid-walk-слоя (#2464-G) —
бан IP, обход прерывается сразу.
NspdBulkServerError когда grid-walk-слой сбойный ЦЕЛИКОМ (были 5xx и
ни одна ячейка не прошла) иначе вернулся бы пустой список,
неотличимый от честного «здесь ничего нет».
До #2464-G это место обещало атомарность, которой не было: grid-walk
глушил любое исключение ячейки и отдавал []. Теперь обещание верно
для отказа слоя и бана, но partial-success внутри слоя ВОЗМОЖЕН:
если часть ячеек упала по сети, а часть прошла, вернётся то, что
собралось, с warning'ом в лог на каждую упавшую ячейку.
Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity.

View file

@ -192,15 +192,30 @@ _INLINE_VELOCITY_SQL = text("""
SELECT
a.room_bucket,
SUM(a.deals_window) AS deals_window,
-- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2
-- объявлен как float (не Optional), и NULL ронял бы контракт API.
-- Пустые комнатности получают площадь 0 м², и это тоже неправда но
-- честный NULL требует правки схемы + перегенерации типов фронта
-- и решения, что писать в area_bin. Отдельным заходом: #2867.
COALESCE(
SUM(a.area_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
)::numeric(10, 2) AS avg_area_m2,
COALESCE(
-- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL →
-- средней цены нет, и это NULL, а не «0 /м²». Схема так и объявлена
-- (TopLayoutRow.avg_price_per_m2_rub: float | None), и Python ниже уже
-- умеет None (пропускает строку во взвешенном роллапе) но COALESCE
-- делал эту ветку недостижимой.
-- Замер 13.08 по проду, окно 6 месяцев. Сработает ноль или нет зависит
-- от того, сколько замапленных проектов попало в радиус, поэтому цифры
-- по слоям: у 616 проектов 2083 пары (проект × комнатность), пустых 635;
-- 323 проекта имеют хотя бы одну пустую комнатность, 80 пустые ВСЕ.
-- При объединении по два пустых остаётся 255 из 1267, по всему городу
-- ноль. То есть чем беднее окрестность участка, тем чаще выдумывался 0.
(
SUM(a.price_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
/ NULLIF(SUM(a.deals_window), 0)
)::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub,
array_agg(DISTINCT a.project_name) AS matched_project_names,
MIN(a.window_start) AS window_start,

View file

@ -169,7 +169,16 @@ def _cell(row: tuple, idx: int) -> object:
def _pct_share_to_percent(value: object) -> float | None:
"""Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1).
#2464-B: продакшен-вызывающих у функции СЕЙЧАС НЕТ. Значение колонки E
раньше писалось в `load_index`, но это категориальная колонка
('open'|'limited'|'closed'|NULL) число в ней фронт отбрасывает в
«неизвестно» и плодит мусорный бакет в `power_summary.by_load_index`.
Функцию оставляю с тестами: она описывает формат листа, и она понадобится
в тот момент, когда под процент загрузки заведут числовую колонку.
Если такого решения не будет удалить вместе с тестом, а не держать молча.
None/мусор None.
"""
num = parse_reserve_number(value)
@ -214,7 +223,9 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
rows_seen += 1
district = _cell(row, 1) # B
load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %)
# Колонку E (степень загрузки ЦП долей) НЕ читаем и не храним: места
# под неё в power_supply_centers нет — load_index категориальный,
# current_load_mva в мегавольт-амперах (#2464-B, см. UPDATE ниже).
reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт)
name_norm = normalize_sc_name(str(sc_name))
@ -223,7 +234,6 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
"reserve": reserve,
"asof": reserve_asof,
"district": str(district).strip() if district else None,
"load_pct": load_pct,
"name_norm": name_norm,
}
@ -236,10 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
reserve_unit = 'МВт',
installed_capacity_mva = :installed,
district = :district,
load_index = COALESCE(
load_index,
CAST(:load_pct AS text)
),
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
-- load_index категориальная колонка
-- ('open'|'limited'|'closed'|NULL, см.
-- data/sql/180_connection_capacity.sql:35), её
-- заполняет rosseti_wfs_loader._map_load_index.
-- Раньше тут стоял COALESCE(load_index,
-- CAST(:load_pct AS text)) при пустой ячейке
-- в колонку легло бы число строкой ("72.5"),
-- а фронтовый classifyLoadIndex такое значение
-- отбрасывает в null («неизвестно»), и в
-- power_summary.by_load_index появился бы
-- бакет с именем "72.5".
-- Сегодня не стреляло только потому, что у всех
-- 3416 строк load_index уже заполнен
-- (open 2741 / limited 346 / closed 329, NULL 0)
-- и COALESCE не проваливался.
capacity_source = 'eesk_35_220',
reserve_asof = :asof
WHERE sc_name_norm = :name_norm

View file

@ -36,6 +36,48 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Конкуренты в радиусе — модульная константа (а не inline f-string), чтобы
# integration-тест мог прогнать EXPLAIN по обеим подстановкам `{class_filter}`.
# Ветка с фильтром до #2464-G не парсилась вообще: ссылалась на алиас `o`,
# которого внутри CTE нет (`missing FROM-clause entry for table "o"`).
_COMPETITORS_SQL_TMPL = """
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median).
# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на
# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка:
@ -173,9 +215,17 @@ def compute_velocity(
# только если явно передан. #38: при NULL реального класса используем
# obj_class_fallback (yandex_match / price_inference) — реальный obj_class
# в приоритете (COALESCE), поведение для размеченных ЖК не меняется.
class_filter = (
"AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else ""
)
# Колонки БЕЗ алиаса: фильтр подставляется ВНУТРЬ latest_obj, где FROM —
# голый domrf_kn_objects. Алиас `o` появляется только во внешнем SELECT,
# и `o.obj_class` здесь давал `missing FROM-clause entry for table "o"`
# (#2464-G, прод-EXPLAIN 13.08). Ошибку глотал except ниже → velocity
# молча выпадал из отчёта. Не срабатывало только потому, что единственный
# вызывающий (parcels.py) obj_class не передаёт.
# NB для первого, кто ветку включит: сравнение точное и регистрозависимое, а
# в проде классы с большой буквы и словарь шире ожидаемого — «Комфорт» 870,
# «Типовой» 224, «Бизнес» 95, «Премиум» 13, «Элит» 12, «Стандарт» 9,
# «Элитный» 4 объекта (замер 13.08). Передавать нужно ровно эти строки.
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :obj_class" if obj_class else ""
# SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx.
# db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction
# (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup).
@ -183,45 +233,7 @@ def compute_velocity(
with db.begin_nested():
comp_rows = (
db.execute(
text(
f"""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
),
text(_COMPETITORS_SQL_TMPL.format(class_filter=class_filter)),
{
"parcel_wkt": parcel_geom_wkt,
"radius_m": radius_km * 1000.0,

View file

@ -10,7 +10,7 @@ API surface:
- create_profile(db, payload) WeightProfile
- update_profile(db, user_id, profile_id, payload) WeightProfile | None
- delete_profile(db, user_id, profile_id) bool
- resolve_weights(db, user_id, profile_id) dict[str, float]
- resolve_weights(db, user_id, profile_id) ResolvedWeights(weights, source)
"""
from __future__ import annotations
@ -19,7 +19,7 @@ import json
import logging
import math
from datetime import datetime
from typing import Any
from typing import Any, NamedTuple
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
return True
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
"""Вернуть эффективные веса для analyze_parcel.
class ResolvedWeights(NamedTuple):
"""Веса + КАКОЙ источник фактически применился (#2811).
Лестница приоритетов ниже по построению стирает разницу между «взял, что
просили» и «не нашёл, взял что было» а метка в ответе /analyze строится
именно на этой разнице. Поэтому источник возвращается вместе с весами, а не
выводится вызывающим из своих же входных параметров. NamedTuple, а не голый
dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко,
молча «весами» этот объект не притворится.
"""
weights: dict[str, float]
source: str # "profile" | "user_default" | "system"
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights:
"""Вернуть эффективные веса для analyze_parcel + фактический их источник.
Порядок приоритетов:
1. profile_id задан загрузить именно этот профиль
2. user_id задан загрузить default-профиль пользователя
3. Иначе вернуть системные значения _SYSTEM_POI_WEIGHTS
1. profile_id задан загрузить именно этот профиль source="profile"
2. user_id задан загрузить default-профиль пользователя source="user_default"
3. Иначе системные значения _SYSTEM_POI_WEIGHTS source="system"
Запрошенный, но НЕ применённый profile_id не тишина: warning с
идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для
/analyze необязательный модификатор, а не адресуемый ресурс; 404 превратил
бы гонку «профиль удалили между списком и анализом» в отказ вместо честно
помеченного ответа. Клиенту хватает source + requested_profile_not_found.
"""
if profile_id is not None and user_id is not None:
profile = get_profile(db, user_id, profile_id)
@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic
logger.debug(
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
)
return dict(profile.weights)
return ResolvedWeights(dict(profile.weights), "profile")
resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system")
if user_id is not None:
profile = get_default_profile(db, user_id)
if profile is not None and profile.weights:
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
return dict(profile.weights)
resolved = ResolvedWeights(dict(profile.weights), "user_default")
logger.debug("resolve_weights: returning system defaults")
return dict(_SYSTEM_POI_WEIGHTS)
if profile_id is not None:
# Сюда попадаем, если запрошенный профиль не применился: owner не передан
# (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights
# пустые. Раньше это был logger.debug, которого на проде нет, — и оценка
# молча считалась не по тем весам (#2811, ранее #2788).
logger.warning(
"resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — "
"фактический источник весов %r",
profile_id,
user_id,
resolved.source,
)
else:
logger.debug("resolve_weights: источник весов %s", resolved.source)
return resolved

View file

@ -406,16 +406,17 @@ def build_beat_schedule() -> dict:
# Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц.
# kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д.
# Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления.
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
# за ~5 недель полного обновления.
#
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
# cooldown 24-48h (проверить через targeted test).
# schedule["scrape-kn-catalog-objects-weekly"] = {
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
# "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
# "kwargs": {"region_code": 66, "max_objects": 300},
# "options": {"queue": "celery"},
# }
@ -430,10 +431,10 @@ def build_beat_schedule() -> dict:
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
# напр. четверг 04:00 UTC.
# напр. четверг 04:00 МСК.
# schedule["scrape-kn-catalog-flats-weekly"] = {
# "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
# "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC
# "schedule": _parse_cron("0 4 * * 4"), # четверг 04:00 МСК
# "kwargs": {"region_code": 66, "max_flats": 300},
# "options": {"queue": "celery"},
# }
@ -542,13 +543,20 @@ def build_beat_schedule() -> dict:
}
# Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings.
# Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК).
# 00:30 МСК ежедневно (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
# Комментарий до #2464-H говорил «00:30 UTC = 03:30 МСК» — считал сдвиг дважды,
# оставшись с эпохи UTC-расписания. Факт по логам beat (10-12.08): «Sending due
# task newbuilding-crossload-nightly» в 21:30 UTC = 00:30 МСК, то есть на три
# часа раньше обещанного.
# Расписание НЕ трогаем: на 00:30 МСК ничего не наложено, а сдвиг на 03:30 МСК
# завёл бы задачу прямо в окно tradein-задания newbuilding_enrich (00:00-01:00 UTC
# = 03:00-04:00 МСК), с которым она делит источник — tradein.houses.
# Не в job_settings (технический ETL, не требует конфигурации UI).
# Идемпотентен через ON CONFLICT (source, ext_house_id).
# Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения.
schedule["newbuilding-crossload-nightly"] = {
"task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload",
"schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК
"schedule": _parse_cron("30 0 * * *"), # 00:30 МСК
"options": {"queue": "celery"},
}

View file

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

View file

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

View file

@ -5,36 +5,16 @@
from __future__ import annotations
# scrape_cadastre зависит от app.scrapers.nspd_bulk_client (PR 2/5).
# Используем importlib.util.find_spec вместо прямого import — иначе
# `import app.workers...` пересоздаёт `app` как Python package и
# перебивает FastAPI instance, привязанный в строке выше → AttributeError
# на app.dependency_overrides.
import importlib.util
# NB: `import app.workers...` здесь запрещён — он пересоздаёт `app` как Python
# package и перебивает FastAPI instance, привязанный ниже → AttributeError на
# app.dependency_overrides. Патчим воркер строкой, через patch("app.workers...").
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
_SCRAPE_CADASTRE_AVAILABLE = (
importlib.util.find_spec("app.workers.tasks.scrape_cadastre") is not None
)
requires_scrape_cadastre = pytest.mark.skipif(
not _SCRAPE_CADASTRE_AVAILABLE,
reason=(
"app.scrapers.nspd_bulk_client отсутствует — нужно смержить main (PR #170). "
"Команда: git rebase origin/main"
),
)
# Токен для тестов (не реальный)
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 с преднастроенными ответами."""
@ -113,8 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]:
# ── Tests ────────────────────────────────────────────────────────────────────
@requires_scrape_cadastre
@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)]
@ -131,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
@ -144,8 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None:
app.dependency_overrides.clear()
@requires_scrape_cadastre
@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()
@ -164,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
@ -174,8 +148,6 @@ def test_create_job_manual_list() -> None:
app.dependency_overrides.clear()
@requires_scrape_cadastre
@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()
@ -189,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())
@ -213,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)
@ -225,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()
@ -239,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()
@ -262,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()
@ -272,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()
@ -289,27 +255,7 @@ 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:
app.dependency_overrides.clear()
@pytest.mark.skip(
reason=(
"X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — "
"Caddy basic_auth + RBAC middleware достаточны). Admin endpoint'ы больше не "
"несут verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode "
"RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без "
"реверта security-решения #437. Тест проверял удалённое поведение."
)
)
def test_create_job_no_token_returns_401() -> None:
"""Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437)."""
client = TestClient(app)
response = client.post(
"/api/v1/admin/cadastre/jobs",
json={"scope": "pilot"},
)
assert response.status_code in (401, 503)

View file

@ -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,25 +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"
@pytest.mark.skip(
reason=(
"X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — "
"Caddy basic_auth + RBAC middleware достаточны). trigger_ekburg_permits больше не "
"несёт verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode "
"RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без "
"реверта security-решения #437. Тест проверял удалённое поведение."
)
)
def test_trigger_no_token_returns_401_or_503() -> None:
"""Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437)."""
client = TestClient(app)
response = client.post(ENDPOINT, json={})
assert response.status_code in (401, 503), response.text

View file

@ -110,9 +110,9 @@ class TestCompetitorsSortOrder:
sorted_rows = sorted(_ROWS_MIXED, key=_sort_key)
first = dict(sorted_rows[0].items())
assert first["site_status"] == "Строящиеся", (
f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'"
)
assert (
first["site_status"] == "Строящиеся"
), f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'"
def test_flat_count_desc_would_break_order(self) -> None:
"""Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми."""
@ -180,14 +180,44 @@ class TestObjPricingPushdown:
#1964: источник агрегатов сменился с сырого objective_lots (alias ol) на
physflat-дедуп CTE obj_lots_latest (alias oll) см. test_obj_pricing_*_physflat
ниже. Сами агрегатные выражения и группировка per-obj_id неизменны.
#2464-D: у среднего цены появились границы правдоподобия (те же, что в двух
соседних запросах по objective_lots) см. test_price_avg_has_sanity_bounds.
"""
sql = self._competitor_sql()
assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "AS avg_price_per_m2_rub" in sql
assert "lots_with_price" in sql
assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
assert "GROUP BY np.domrf_obj_id" in sql
def test_price_avg_has_sanity_bounds(self) -> None:
"""#2464-D: среднее цены считается по лотам в границах правдоподобия.
Среднее считается ПО ПРОЕКТУ, поэтому один лот держит группу без ограничения
сверху: максимум в objective_lots 19.2 млн /м² (замер 13.08). Границы
30000..600000 уже стоят в двух соседних запросах по этой же таблице; здесь
их не было. Дальше значение уходит в market_avg_price и на экран.
"""
sql = self._competitor_sql()
bounds = "WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000"
assert (
f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql
), "среднее цены должно фильтроваться границами правдоподобия (#2464-D)"
# Тот же набор кормит счётчик выборки — иначе счётчик обещает шире, чем
# реально участвовало в среднем.
assert (
f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql
), "lots_with_price должен считать ту же популяцию, что и среднее"
# FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены
# молча урежут счётчики продаж/остатка, которые считают ВСЕ лоты.
assert (
"COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
), "units_sold не должен зависеть от границ цены"
assert (
"COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
), "units_available не должен зависеть от границ цены"
def test_obj_pricing_dedups_physflat_inline(self) -> None:
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.

View file

@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None:
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_missing_profile_is_not_labelled_profile() -> None:
"""#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'.
Три способа промахнуться мимо профиля (все три воспроизведены живым запросом
на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id.
В mock-БД профилей нет значит применились системные веса, и ответ обязан
это признать, а не утверждать, что считал по профилю.
"""
from app.core.db import get_db
from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS
for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"):
db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}")
assert resp.status_code == 200, resp.text
wp = resp.json()["weights_profile"]
# sanity: веса и правда системные, промах реальный
assert wp["weights_applied"]["tram_stop"] == pytest.approx(
_SYSTEM_POI_WEIGHTS["tram_stop"]
)
assert wp["source"] != "profile", (
f"?{qs}: применились системные веса, а метка source='profile'"
"ответ утверждает то, чего не было (#2811)"
)
assert wp["source"] == "system"
# «что просили» не теряется: запрошенный id + явный признак промаха
assert wp["profile_id"] == 999999
assert wp["requested_profile_applied"] is False
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_found_profile_keeps_label_and_flag() -> None:
"""Обратная сторона: профиль найден → source='profile', флаг промаха False."""
from datetime import UTC, datetime
import app.services.site_finder.weight_profiles as wp_module
from app.core.db import get_db
from app.services.site_finder.weight_profiles import WeightProfile
profile = WeightProfile(
id=7,
user_id="user-1",
profile_name="test",
weights={"tram_stop": -0.4},
is_default=False,
description=None,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
original = wp_module.get_profile
wp_module.get_profile = lambda _db, uid, pid: profile
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1")
assert resp.status_code == 200, resp.text
wp = resp.json()["weights_profile"]
assert wp["source"] == "profile"
assert wp["requested_profile_applied"] is True
assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4)
finally:
wp_module.get_profile = original
app.dependency_overrides.clear()
_stop_patches()

View file

@ -10,6 +10,7 @@ NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок `
from __future__ import annotations
import os
from pathlib import Path
import pytest
@ -40,3 +41,79 @@ def _clear_dependency_overrides():
app.dependency_overrides.clear()
except Exception:
pass
# ── Бюджет пропусков (#2745) ──────────────────────────────────────────────────
#
# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта:
# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и
# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI
# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть
# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в
# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал
# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно.
#
# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и
# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не
# ошибка; ошибка — пропуск без записи.
#
# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без
# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить.
#
# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона,
# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких
# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml).
_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt"
_observed_skips: set[str] = set()
def _allowed_skips() -> set[str]:
if not _SKIP_ALLOWLIST_PATH.exists():
return set()
lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines()
return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())}
def _record_skip(report) -> None:
if report.skipped:
# nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может
# отсутствовать, а не каждую её параметризацию.
_observed_skips.add(report.nodeid.split("[", 1)[0])
def pytest_runtest_logreport(report) -> None:
_record_skip(report)
def pytest_collectreport(report) -> None:
# Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в
# logreport, и nodeid у него — путь файла.
_record_skip(report)
def pytest_sessionfinish(session, exitstatus) -> None:
unlisted = sorted(_observed_skips - _allowed_skips())
if not unlisted:
return
head = (
f"НЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:"
)
print(f"\n{head}")
for nodeid in unlisted:
print(f" - {nodeid}")
print(
"Почини тест либо внеси его в skip_allowlist.txt с причиной — "
"пропуск без записи неотличим от пройденной проверки."
)
# #2871: под Actions дублируем в ::error:: — иначе сообщение тонет.
# 13.08 этот сторож четыре прогона подряд ронял job'у совершенно правильно,
# а его строка лежала посреди тысячи других (обычный print, по-русски) —
# и поиск по «FAILED / ERROR» её не находил. Причину искали три часа
# в диске, раннере, покрытии и кэше. Сторож, который роняет прогон,
# обязан кричать так, чтобы его нашли.
if os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"):
print(f"::error::{head} " + "; ".join(unlisted))
if exitstatus == 0:
session.exitstatus = 1

View file

@ -39,6 +39,7 @@ from sqlalchemy.orm import Session
from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL
from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL
from app.services.site_finder.velocity import _COMPETITORS_SQL_TMPL
from tests.integration.conftest import requires_test_db
# NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time
@ -103,9 +104,9 @@ class TestNeighborsSummarySql:
for kw in forbidden_aliases:
# ищем паттерн ``WITH <kw> AS (`` или ``, <kw> AS (`` — оба
# формы CTE-биндинга.
assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, (
f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
)
assert (
f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql
), f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
# ── parcel_ird_overlaps SQL ──────────────────────────────────────────────────
@ -167,3 +168,39 @@ class TestPsycopg3CastAntipattern:
f"{name} содержит psycopg v3 antipattern: {matches}. "
f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md."
)
# ── velocity: конкуренты в радиусе (#2464-G) ─────────────────────────────────
class TestVelocityCompetitorsSql:
"""``_COMPETITORS_SQL_TMPL`` из ``app.services.site_finder.velocity``.
Шаблон подставляется в двух видах, и **вторая подстановка до #2464-G
не парсилась вообще**: фильтр класса ссылался на алиас ``o``, который
существует только во внешнем SELECT, а подставляется фильтр ВНУТРЬ CTE
``latest_obj`` (FROM domrf_kn_objects, без алиаса)
``missing FROM-clause entry for table "o"`` (прод-EXPLAIN 13.08).
Почему это не падало в проде: единственный вызывающий
(``analyze_parcel``) ``obj_class`` не передаёт ветка мёртвая.
Падало бы молча исключение глотает ``except`` в ``compute_velocity``,
и блок velocity просто исчезал бы из отчёта с одной строкой в логе.
Тест закрывает обе ветки, а не только ту, что сегодня исполняется.
"""
@requires_test_db
@pytest.mark.integration
@pytest.mark.parametrize(
"class_filter",
["", "AND COALESCE(obj_class, obj_class_fallback) = :obj_class"],
ids=["no_class_filter", "with_class_filter"],
)
def test_explain_competitors(self, phantom_check_session: Session, class_filter: str) -> None:
"""Обе подстановки шаблона парсятся и планируются против реальной схемы."""
_explain_text(
phantom_check_session,
_COMPETITORS_SQL_TMPL.format(class_filter=class_filter),
{"parcel_wkt": _EKB_WKT, "radius_m": 3000.0, "obj_class": "комфорт"},
)

View file

@ -190,6 +190,70 @@ class TestGetFeaturesInBboxGrid:
# 4 cells: 1 error + 3 good_feat → 1 unique feature
assert any(f.feature_id == "feat-ok" for f in result)
# ── #2464-G: отказ слоя больше не маскируется пустым результатом ──────────
def _grid(self, side_effect: Any, *, grid_n: int = 2) -> list[NSPDFeature]:
"""Прогнать grid-walk с подменённым wms_feature_info."""
mock_client_instance = AsyncMock()
mock_client_instance.wms_feature_info = AsyncMock(side_effect=side_effect)
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
with patch(
"app.scrapers.nspd_bulk_client.NSPDBulkClient",
return_value=mock_client_instance,
):
return NSPDClient().get_features_in_bbox_grid(
36328, self.BBOX, grid_n=grid_n, step_m=1.0
)
def test_waf_403_aborts_grid_instead_of_empty_result(self) -> None:
"""403 WAF на ячейке — бан IP, обход прерывается.
До #2464-G исключение глушилось и метод отдавал [] — «зон здесь нет»,
неотличимое от честного пустого слоя. На проде это 124 дампа из 669
с territorial_zones_count=0, у 50 из которых соседний legacy-слой
данные всё-таки нашёл.
"""
from app.scrapers.nspd_bulk_client import NspdBulkWafError
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
raise NspdBulkWafError("HTTP 403 WAF")
with pytest.raises(NspdBulkWafError):
self._grid(_wms)
def test_all_cells_5xx_raises_instead_of_empty_result(self) -> None:
"""Все ячейки упали с 5xx — слой лёг целиком, а не «пуст»."""
from app.scrapers.nspd_bulk_client import NspdBulkServerError
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
raise NspdBulkServerError("HTTP 500 ServiceException")
with pytest.raises(NspdBulkServerError):
self._grid(_wms)
def test_partial_5xx_keeps_data_and_does_not_raise(self) -> None:
"""Часть ячеек 5xx, часть прошла — отдаём собранное, не бросаем.
Контроль к двум тестам выше: правка НЕ превращает любую ошибку в отказ.
Именно этот тест ловил бы обратную крайность «чуть что, роняем обход».
"""
from app.scrapers.nspd_bulk_client import NspdBulkServerError
good_feat = _make_bulk_feature("feat-ok", {"cad_num": "66:41:001:1"})
call_n: list[int] = [0]
async def _wms(*args: Any, **kwargs: Any) -> list[Any]:
call_n[0] += 1
if call_n[0] <= 2:
raise NspdBulkServerError("HTTP 500 ServiceException")
return [good_feat]
result = self._grid(_wms)
assert any(
f.feature_id == "feat-ok" for f in result
), "успешные ячейки должны попасть в результат, даже если часть слоя упала"
def test_returns_nspd_feature_instances(self) -> None:
"""Метод возвращает list[NSPDFeature] а не NSPDBulkFeature."""
bulk_feat = _make_bulk_feature("feat-xyz", {"cad_num": "66:41:001:1"})

View file

@ -133,19 +133,33 @@ class TestFactorFromCount:
assert "12.5 мес истории" in f_frac.note
# ── _coverage_factor — покрытие domrf↔objective в % ────────────────────────────
# ── _coverage_factor — покрытие рынка ценами Objective в % ─────────────────────
class TestCoverageFactor:
def test_low_coverage_percent_in_note(self) -> None:
# Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15).
# 2.5% покрытия → low, % в ноте (структурный §15).
f = _coverage_factor(0.025)
assert f.level == "low"
assert f.value == 0.025
assert "2.5%" in f.note
# #1963: нота человеческая, без внутр.жаргона «domrf↔objective».
assert "domrf" not in f.note
assert "будущ" in f.note # говорит про будущее предложение/проекты
def test_note_names_what_is_actually_measured(self) -> None:
"""#2464-H: нота называет ближние ЖК и цену, а не «будущие проекты».
Значение фактора ВСЕГДА приходит из `analyze.market_data_coverage_pct`
= competitors_priced / competitors_total, то есть доля ближних ЖК (3 км)
с ценой из Objective. Слот `supply_layers.domrf_coverage`, под который
писалась старая формулировка, никто не заполняет.
"""
f = _coverage_factor(0.4)
assert "ближних ЖК" in f.note, f.note
assert "Objective" in f.note, f.note
assert (
"будущ" not in f.note
), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами"
def test_high_coverage(self) -> None:
f = _coverage_factor(0.75)
@ -158,6 +172,14 @@ class TestCoverageFactor:
assert "неизвестн" in f.note
assert "domrf" not in f.note
def test_factor_key_unchanged(self) -> None:
"""Ключ фактора остаётся `domrf_coverage` — его читает фронт.
Контроль к правке #2464-H: меняем только человеческий текст, не контракт
(ForecastConfidenceBlock / ConfidencePanel маппят имя в RU-подпись).
"""
assert _coverage_factor(0.4).name == "domrf_coverage"
def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None:
# BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден).
# До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) —

View file

@ -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), поэтому
# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ.
#
# Свойство, ради которого они писались, от дампа не зависит и выражается
# страницей из блоков, которые уже лежат в этом файле:
# • несколько <h2>-секций с годами разбираются в одном проходе;
# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ,
# поэтому «2.» встречается дважды — и site_name всё равно обязан быть
# уникальным (номер сам по себе ключом не является);
# • блоки лежат внутри контейнера <div> — это ветка `_extract_krt_section`
# через `find_parent`, которой одиночные блоки выше не касаются вовсе.
#
# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778):
# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора
# реального документа, синтетической страницей оно не заменяется. На проде
# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но
# автоматической проверки этого больше нет — она вернётся, когда в репозитории
# появится образец страницы.
_MULTISITE_PAGE = (
"<div class='content'>"
+ _BRUSNIKA_BLOCK
+ _BABUSHKINA_BLOCK
+ textwrap.dedent(
"""\
<p>&nbsp;<strong>2. КРТ нежилой застройки в районе ул. Краснофлотцев:</strong></p>
<p>Площадь территории: 3,65 га;<br />\
Градостроительный потенциал: 117 640 кв. м;<br />\
Всего многоквартирных домов: 13.</p>
"""
)
+ "</div>"
)
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

View file

@ -202,9 +202,7 @@ class TestParseTable11:
# Реальная зона должна быть в результате
assert "Зона жилой застройки" in zone_names
# Артефакты (zone_name пуст, не is_total) отброшены
non_total_no_zone = [
r for r in result if not r["zone_name"] and not r.get("is_total")
]
non_total_no_zone = [r for r in result if not r["zone_name"] and not r.get("is_total")]
assert non_total_no_zone == []
# Итого-строка осталась
assert any(r.get("is_total") for r in result)
@ -483,15 +481,23 @@ class TestParsePptTepMocked:
# ── Тест на реальном PDF-образце (skip в CI) ─────────────────────────────────
_REAL_PDF_PATH = Path(
"C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf"
#
# Путь repo-relative, а не абсолютный `C:/Users/user/source/repos/...`: с
# Windows-путём условие пропуска не выполнялось НИ НА ОДНОЙ машине кроме одного
# старого дев-бокса, т.е. эти 6 тестов были не «пропущены по среде», а мертвы.
# Образец не в git (untracked `.playwright-mcp/`), поэтому в CI пропуск остаётся —
# но теперь он снимается тем, что файл кладут в репо-каталог на любой ОС.
_REAL_PDF_PATH = (
Path(__file__).resolve().parents[4]
/ ".playwright-mcp"
/ "ekb-docs"
/ "ppt2018_22823_poyasnit_PPT.pdf"
)
@pytest.mark.skipif(
not _REAL_PDF_PATH.exists(),
reason="Реальный PDF-образец отсутствует (только для локального запуска)",
reason=f"Реальный PDF-образец отсутствует ({_REAL_PDF_PATH}) — только локальный запуск",
)
class TestParsePptTepRealPdf:
"""Интеграционные тесты на реальном образце ppt2018_22823."""

View file

@ -1393,6 +1393,65 @@ async def test_grid_walk_marks_layer_failed_when_all_cells_500() -> None:
assert layer_failed is True
@pytest.mark.asyncio
async def test_grid_walk_reraises_waf_instead_of_swallowing() -> None:
"""#2464-A: 403 WAF прерывает обход, а не превращается в «cell не дошёл».
Контракт harvest_quarter (Raises:) обещает пробросить NspdBulkWafError, но
голый `except Exception` в цикле ячеек его глотал. Прод-замер 13.08:
23 job'а в cadastre_jobs, суммарно 50 WAF-блоков — и НИ ОДНОГО упавшего
job'а. То есть бан ни разу не остановил сбор, как обещано.
"""
from app.scrapers.nspd_bulk_client import NspdBulkWafError
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=NspdBulkWafError("HTTP 403 WAF"))
with pytest.raises(NspdBulkWafError):
await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
@pytest.mark.asyncio
async def test_grid_walk_reraises_rate_limit() -> None:
"""#2464-A: исчерпанные ретраи — тоже не «пустой слой» (caller может retry)."""
from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=NspdBulkRateLimitError("429"))
with pytest.raises(NspdBulkRateLimitError):
await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
@pytest.mark.asyncio
async def test_grid_walk_still_tolerates_network_error_per_cell() -> None:
"""Контроль обратной крайности: сетевая ошибка ячейки обход НЕ роняет.
Зелёный с обеих сторон правки проверяет, что #2464-A не превратил любое
исключение в отказ квартала.
"""
from app.services.cadastre.bulk_harvest import _grid_walk_category
db = _mock_db_grid_bbox()
client = AsyncMock()
client.wms_feature_info = AsyncMock(side_effect=OSError("connection reset"))
upserted, requests, layer_failed = await _grid_walk_category(
db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3
)
assert upserted == 0
assert requests == 9
assert layer_failed is False, "сетевые сбои НЕ должны поднимать layer_failed"
@pytest.mark.asyncio
async def test_grid_walk_layer_not_failed_when_some_cells_ok() -> None:
"""Issue #252: если хоть один cell прошёл — layer_failed=False (слой жив, просто пуст)."""

View file

@ -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}"
)
# ──────────────────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,98 @@
# Объявленные пропуски сьюта backend/.
#
# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не
# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала
# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда,
# когда на неё понадобилось опереться (#2722, #2729, #2740).
#
# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий.
# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ
# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра.
#
# Список — НАДмножество сред: на CI (ubuntu + libpango + postgres-сервис) часть
# записей не срабатывает, на ноутбуке без них — срабатывает. Запись, чей пропуск
# не случился, безвредна; пропуск без записи — нет.
# ── WeasyPrint: нужны native-либы (Pango/cairo/GObject) ───────────────────────
# В CI ЭТИ ТЕСТЫ ИДУТ: ci.yml ставит libcairo2/libpango-1.0-0/libpangoft2-1.0-0.
# Записи нужны только для macOS-dev, где libgobject-2.0-0 нет.
tests/api/v1/test_parcels_forecast.py::test_export_forecast_pdf_returns_pdf
tests/services/exporters/test_report_pdf.py::TestConfidenceSectionParity::test_dict_shaped_factor_produces_valid_pdf
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_accepts_as_dict_input
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_non_trivial_length
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_pdf_magic_bytes
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_empty_report_still_valid_pdf
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_garbage_input_does_not_crash
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_partial_report_some_sections
tests/services/generative/test_exporters.py::test_pdf_export_produces_pdf_bytes
tests/test_layout_tz_pdf.py
# ── phantom-column gate: нужна КОПИЯ ПРОДОВОЙ схемы ───────────────────────────
# Смысл этих тестов — поймать колонку, которой нет в реальной БД, поэтому пустой
# сервис-контейнер их не заменяет: против пустой схемы они дали бы красноту, не
# относящуюся к делу. Условие: TEST_DATABASE_URL на SSH-туннель к проду
# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py.
# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**.
tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap
tests/integration/test_analyze_parcels_sql.py::TestVelocityCompetitorsSql::test_explain_competitors
tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary
tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_supply_batch_query
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_full_cte_query
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_radius_query
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_velocity_competitor_query_columns
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rns_columns
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rve_columns
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_recent_permits_query
tests/integration/test_phantom_columns.py::TestMvLayoutVelocity::test_velocity_by_room_bucket
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_corpus_room_month_columns
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_mapping_columns
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_sold_count_query
# ── SQL-логика на живой Postgres ──────────────────────────────────────────────
# В CI ЭТИ ТЕСТЫ ИДУТ с #2745 (postgres-сервис в ci.yml); до него не бежали ни
# разу с момента написания. Схема не нужна — строят себе временные таблицы.
# Локально: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:16
tests/sql/test_ddu_price_indicator.py::test_basis_and_previous_index
tests/sql/test_ddu_price_indicator.py::test_packaged_deal_bucketed_by_per_unit_area
tests/sql/test_ddu_price_indicator.py::test_prev_period_value_skips_filtered_quarter
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_hand_computed_weighted_average
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_no_zero_months_weighted_equals_naive
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_nullif_prevents_division_by_zero
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_weighted_differs_from_naive_sparse_project
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_zero_deal_months_skew_naive_avg
tests/sql/test_velocity_alerts.py::test_lookback_anchors_to_latest_data_month
tests/sql/test_velocity_alerts.py::test_sharp_drop_is_flagged
# ── Живая сеть NSPD (nspd.gov.ru) ─────────────────────────────────────────────
# Ходят в внешний сервис: в гейте это флейк и зависимость от чужого аптайма.
# Запуск вручную: uv run pytest tests/scrapers/ -m slow / -m integration -s
tests/scrapers/test_nspd_bulk_client.py::test_list_objects_in_building_real
tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_empty
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 ───────────────────────────────────────────────
# ДОЛГ, а не норма. Было 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
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_tep_has_rows
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_itogo
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_rows

View file

@ -0,0 +1,182 @@
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
корневого каталога не задача этого PR, поэтому здесь проверяются инварианты, которые
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
Тест не требует БД только чтение файлов.
"""
from __future__ import annotations
import re
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[3]
_AUTH_SQL_DIR = _REPO_ROOT / "data" / "sql" / "auth"
_BOOTSTRAP_SQL_DIR = _REPO_ROOT / "ops" / "db-bootstrap"
_DEPLOY_WORKFLOW = _REPO_ROOT / ".forgejo" / "workflows" / "deploy.yml"
_FILENAME_RE = re.compile(r"^(\d{3})_[a-z0-9_]+\.sql$")
# Признаки утёкшего пароля в git. bcrypt-хеши ($2a$/$2b$/$2y$) запрещены наравне с
# plaintext: хеш из репозитория брутфорсится офлайн и переживает ротацию пароля,
# оставаясь в истории коммитов. Конвенция репо — сид вставляет password_hash = NULL,
# значения проставляются на проде (прецедент: tradein м.193).
_SECRET_PATTERNS = (
re.compile(r"\$2[aby]\$\d{2}\$"), # bcrypt hash
re.compile(r"PASSWORD\s+'", re.IGNORECASE), # CREATE/ALTER ROLE ... PASSWORD 'literal'
)
def _auth_sql_files() -> list[Path]:
"""Файлы, к которым применимы конвенции миграций (имя NNN_*, обёртка BEGIN/COMMIT)."""
return sorted(_AUTH_SQL_DIR.glob("*.sql"))
def _secret_scanned_files() -> list[Path]:
"""Файлы, по которым гоняется поиск паролей/хешей — ШИРЕ, чем список миграций.
НЕ «унифицируй» этот список с _auth_sql_files(): разделение намеренное.
* data/sql/auth/*.sql миграции: обязаны иметь имя NNN_snake_case.sql и обёртку
BEGIN;/COMMIT; (см. test_filenames_and_unique_prefix, test_migrations_are_transactional).
* ops/db-bootstrap/*.sql bootstrap: НЕ миграции, поэтому намеренно без NNN-префикса
(порядок задан явными шагами deploy.yml, не сортировкой) и намеренно без транзакции
(CREATE DATABASE запрещён внутри транзакционного блока). Прогонять по ним проверки
имён/BEGIN-COMMIT значит сломать тест на корректных файлах.
А вот запрет на пароли применим к обоим каталогам, и именно bootstrap здесь важнее:
единственное место в репозитории с конструкцией `ALTER ROLE ... PASSWORD` это
ops/db-bootstrap/set_*_password.sql, то есть ровно тот файл, куда проще всего однажды
«временно» вписать литерал вместо чтения из env. Другого контроля на это нет:
в .pre-commit-config.yaml из секрет-сканеров только detect-private-key (bcrypt не ловит),
а репо-wide grep невозможен caddy/users.caddy.snippet легально содержит bcrypt-хеши
действующих логинов.
"""
return _auth_sql_files() + sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql"))
def test_scanned_dirs_are_not_empty() -> None:
"""Sanity: пути до каталогов не разъехались (иначе все проверки ниже — пустые).
Red => каталог переименован/перенесён, а тест этого не заметил бы: `glob` по
несуществующему пути возвращает [], и все циклы ниже стали бы no-op'ами, оставаясь
зелёными. Особенно опасно для проверки паролей «зелено, потому что ничего не проверено».
"""
assert _auth_sql_files(), f"Не найдено *.sql в {_AUTH_SQL_DIR}"
assert sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")), f"Не найдено *.sql в {_BOOTSTRAP_SQL_DIR}"
def test_filenames_and_unique_prefix() -> None:
"""Имя вида NNN_snake_case.sql, префикс NNN уникален.
Red => прод применяет файлы в порядке `ls | sort`; два файла с одним NNN дают
неоднозначный порядок (например, роль/гранты раньше таблиц). Присвой следующий
свободный номер.
"""
seen: dict[str, str] = {}
bad_names: list[str] = []
collisions: list[str] = []
for path in _auth_sql_files():
m = _FILENAME_RE.match(path.name)
if m is None:
bad_names.append(path.name)
continue
prefix = m.group(1)
if prefix in seen:
collisions.append(f"{path.name} (префикс {prefix} уже у {seen[prefix]})")
else:
seen[prefix] = path.name
assert not bad_names, (
f"Имена не соответствуют NNN_snake_case.sql: {bad_names}. "
"Порядок применения на проде определяется сортировкой имён."
)
assert not collisions, "Дублирующийся NNN-префикс: " + "; ".join(collisions)
def test_migrations_are_transactional() -> None:
"""Каждая миграция обёрнута в BEGIN; ... COMMIT; (.claude/rules/sql.md).
Red => частично применённая миграция оставит БД auth в промежуточном состоянии:
деплой падает на ON_ERROR_STOP, а уже выполненный DDL не откатывается.
"""
broken: list[str] = []
for path in _auth_sql_files():
text = path.read_text(encoding="utf-8")
statements = [
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("--")
]
if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;":
broken.append(path.name)
assert (
not broken
), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
def test_no_concurrent_index_in_migrations() -> None:
"""Ни одной CREATE/DROP INDEX CONCURRENTLY в data/sql/auth/*.sql.
Red => миграция гарантированно падает на проде: CONCURRENTLY нельзя выполнять внутри
транзакционного блока (Postgres: 25001 «CREATE INDEX CONCURRENTLY cannot run inside a
transaction block»), а обёртка BEGIN;/COMMIT; здесь обязательна для всех файлов
(test_migrations_are_transactional). Две проверки по отдельности зелёные, а вместе
невыполнимые поэтому запрет нужен явный: комбинация ловится только здесь.
Нужен CONCURRENTLY на большой таблице это отдельный ручной прогон вне auto-apply,
а не файл в этом каталоге.
"""
hits: list[str] = []
for path in _auth_sql_files():
text = path.read_text(encoding="utf-8")
for line_no, line in enumerate(text.splitlines(), start=1):
if line.lstrip().startswith("--"):
continue # комментарий может объяснять запрет, не нарушая его
if re.search(r"\bCONCURRENTLY\b", line, re.IGNORECASE):
hits.append(f"{path.name}:{line_no}: {line.strip()}")
assert not hits, "CONCURRENTLY внутри BEGIN/COMMIT — упадёт на деплое: " + "; ".join(hits)
def test_no_password_material_in_auth_sql() -> None:
"""Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей.
Покрытие шире каталога миграций сознательно обоснование в _secret_scanned_files().
Red => пароль/хеш попал в git. Убери значение: сид вставляет password_hash = NULL,
пароль роли ставится из env через ops/db-bootstrap/set_auth_app_password.sql
(значение приезжает из .env.runtime на VPS и в репозитории не существует).
"""
hits: list[str] = []
for path in _secret_scanned_files():
rel = path.relative_to(_REPO_ROOT).as_posix()
text = path.read_text(encoding="utf-8")
for line_no, line in enumerate(text.splitlines(), start=1):
if line.lstrip().startswith("--"):
continue # комментарии описывают запрет, а не нарушают его
for pattern in _SECRET_PATTERNS:
if pattern.search(line):
hits.append(f"{rel}:{line_no}: {line.strip()}")
assert not hits, "Похоже на пароль/хеш в SQL: " + "; ".join(hits)
def test_deploy_workflow_applies_auth_migrations() -> None:
"""deploy.yml реально прогоняет data/sql/auth/*.sql.
Каталог обособлен намеренно: основной цикл миграций использует `ls -1 data/sql/*.sql`
и в подкаталоги НЕ рекурсирует (чтобы файлы auth физически не могли примениться в БД
gendesign). Обратная сторона без отдельного цикла в deploy.yml эти файлы не
применяются вообще и никто этого не заметит. Red => wiring удалён или переименован.
"""
workflow = _DEPLOY_WORKFLOW.read_text(encoding="utf-8")
assert "data/sql/auth/*.sql" in workflow, (
f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth "
"не применяются на деплое."
)
assert (
"ops/db-bootstrap/create_auth_db.sql" in workflow
), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."

View file

@ -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
@ -399,41 +381,3 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N
assert len(captured_params) == 1
assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID
assert captured_params[0]["user_id"] == "user-test"
# ── Auth ───────────────────────────────────────────────────────────────────────
_TOKEN_REMOVED_REASON = (
"App-level X-Admin-Token header удалён 2026-05-23 (см. docstring "
"app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, "
"двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. "
"В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта "
"security-решения. Тест проверял удалённое поведение."
)
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
"""Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23)."""
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
client = TestClient(app)
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1"},
# без headers — нет X-Admin-Token
)
assert r.status_code == 401
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None:
"""Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23)."""
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
client = TestClient(app)
r = client.get(
"/api/v1/admin/site-finder/weight-profiles",
params={"user_id": "user-1"},
headers={"X-Admin-Token": "wrong-token"},
)
assert r.status_code == 401

View file

@ -0,0 +1,497 @@
"""DSN и ленивый engine БД `auth` — `app/core/config.py` + `app/core/auth_db.py`.
Эпик «единый вход», стадия 3. Три группы:
1. Дефолты. Они ЧАСТЬ КОНТРАКТА PR, а не декорация: пока Caddy basic_auth стоит,
прод обязан вести себя ровно как до эпика флаг выключен, DSN не сконфигурирован,
engine не создаётся, отсутствие AUTH_* в окружении не роняет старт.
2. Сборка DSN из частей: приоритет явного URL, экранирование секрета, пустые
значения переменных прод-дефолты (а не мусорный DSN и не падение на импорте).
3. `auth_db`: ленивость, кеш, внятная ошибка вместо утечки пароля.
Сеть здесь не нужна: `create_engine` пул создаёт лениво и к серверу не ходит.
"""
from __future__ import annotations
import os
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
import pytest
from pydantic import SecretStr, ValidationError
from sqlalchemy.engine import make_url
from app.core import auth_db
from app.core.auth_db import AuthDatabaseNotConfiguredError, AuthDatabaseUnreachableError
from app.core.config import Settings, settings
_AUTH_ENV_VARS = (
"AUTH_MODE",
"AUTH_DATABASE_URL",
"AUTH_DB_PASSWORD",
"AUTH_DB_HOST",
"AUTH_DB_PORT",
"AUTH_DB_NAME",
"AUTH_DB_USER",
"SESSION_COOKIE_NAME",
"SESSION_TTL_HOURS",
)
# Заведомо синтаксически корректный DSN на несуществующий хост: engine по нему
# создаётся, но соединение не открывается (пул ленивый), поэтому тесты офлайновы.
_OFFLINE_DSN = "postgresql+psycopg://auth_app:pw@127.0.0.1:1/auth"
@pytest.fixture
def clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одной AUTH_*/SESSION_* переменной — тест дефолтов не зависит от машины."""
for name in _AUTH_ENV_VARS:
monkeypatch.delenv(name, raising=False)
@pytest.fixture(autouse=True)
def _reset_engine_cache() -> Iterator[None]:
"""Ни один тест не оставляет за собой закешированный engine БД `auth`."""
auth_db.reset_auth_db()
yield
auth_db.reset_auth_db()
# ---------------------------------------------------------------------------
# 1. Дефолты
# ---------------------------------------------------------------------------
def test_session_mode_is_off_and_unconfigured_by_default(clean_env: None) -> None:
"""Свежие настройки без AUTH_*: режим legacy, DSN пуст — и это НЕ ошибка."""
fresh = Settings()
assert fresh.auth_mode == "legacy"
assert fresh.auth_session_enabled is False
assert fresh.resolved_auth_database_url == ""
def test_live_settings_singleton_is_off() -> None:
"""Тот же инвариант на настоящем синглтоне, которым пользуется приложение."""
assert settings.auth_mode == "legacy"
assert settings.auth_session_enabled is False
@pytest.mark.parametrize("raw", ["", " ", "LEGACY", " legacy "])
def test_blank_or_odd_case_auth_mode_falls_back_to_legacy(
clean_env: None, monkeypatch: pytest.MonkeyPatch, raw: str
) -> None:
"""`AUTH_MODE=` (или регистр/пробелы) → legacy, а не ValidationError на импорте.
`settings = Settings()` выполняется на уровне модуля: невалидное значение уронило бы
ИМПОРТ конфига и увело контейнер в restart-loop. Сценарий бытовой ops копирует
блок AUTH_* в .env.runtime и заполняет только пароль.
"""
monkeypatch.setenv("AUTH_MODE", raw)
assert Settings().auth_mode == "legacy"
def test_meaningful_garbage_in_auth_mode_still_fails(
clean_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`AUTH_MODE=off` — опечатка со смыслом, и она обязана падать.
Молча трактовать её как legacy значило бы тихо оставить продукт на trusted-header
после того, как последний PR эпика снимет popup.
"""
monkeypatch.setenv("AUTH_MODE", "off")
with pytest.raises(ValidationError):
Settings()
@pytest.mark.parametrize(
("mode", "enabled"), [("legacy", False), ("dual", True), ("db_only", True)]
)
def test_auth_session_enabled_is_derived_from_mode(
clean_env: None, monkeypatch: pytest.MonkeyPatch, mode: str, enabled: bool
) -> None:
"""Свойство `auth_session_enabled` = «режим не legacy» — единый выключатель реестра.
Оно и закорачивает `resolve_session_token` / `require_auth_db_configured`; разница
dual vs db_only ему не видна и не должна быть (она про фолбэк в rbac_guard).
"""
monkeypatch.setenv("AUTH_MODE", mode)
assert Settings().auth_session_enabled is enabled
def test_default_host_is_this_stacks_postgres(clean_env: None) -> None:
"""🪤 Дефолт хоста — `postgres`, и это ЗЕРКАЛЬНО «Мере», а не копия с неё.
У «Меры» дефолт `gendesign-postgres`, потому что внутри её стека имя `postgres`
занято её собственным контейнером. У «Птицы» наоборот: её стек главный, сервис
`postgres` корневого docker-compose.prod.yml и есть сервер с БД `auth`. Алиас
`gendesign-postgres` живёт только во внешней сети `shared`, куда входят не все
сервисы (beat нет), поэтому дефолтом он быть не может.
"""
fresh = Settings()
assert fresh.auth_db_host == "postgres"
assert fresh.auth_db_host != "gendesign-postgres"
assert fresh.auth_db_port == 5432
assert fresh.auth_db_name == "auth"
assert fresh.auth_db_user == "auth_app"
def test_cookie_defaults_match_the_other_product(clean_env: None) -> None:
"""Имя куки и TTL обязаны совпадать с «Мерой» — иначе общая сессия не общая.
Имя историческое («tradein_» уже ни о чём не говорит); переименование
разлогинивает всех сразу в обоих продуктах, поэтому оно закреплено тестом.
"""
fresh = Settings()
assert fresh.session_cookie_name == "tradein_session"
assert fresh.session_ttl_hours == 720
# ---------------------------------------------------------------------------
# 2. Сборка DSN
# ---------------------------------------------------------------------------
def test_explicit_url_wins_over_parts(clean_env: None) -> None:
"""AUTH_DATABASE_URL — аварийный обход (другой хост, sslmode): выигрывает всегда."""
fresh = Settings(
auth_database_url=" postgresql+psycopg://u:p@elsewhere:6432/auth?sslmode=require ",
auth_db_password=SecretStr("ignored"),
auth_db_host="postgres",
)
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://u:p@elsewhere:6432/auth?sslmode=require"
)
def test_dsn_built_from_password_and_defaults(clean_env: None) -> None:
"""Включение на проде = одна переменная: пароль + прод-дефолты остальных частей."""
fresh = Settings(auth_db_password=SecretStr("s3cret"))
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://auth_app:s3cret@postgres:5432/auth"
)
def test_password_special_chars_survive_round_trip(clean_env: None) -> None:
"""Пароль экранируется: `@`/`/`/`:`/`#`/`%` иначе порвали бы URL по его грамматике.
Проверяем не наличие процентов в строке, а РАЗБОР обратно: важно, что SQLAlchemy
видит тот же пароль и, главное, тот же хост/базу. Незакавыченный `@` молча увёл бы
подключение на другой хост.
"""
raw = "p@ss:w/rd#1%zz?x"
url = make_url(Settings(auth_db_password=SecretStr(raw)).resolved_auth_database_url)
assert url.password == raw
assert url.host == "postgres"
assert url.port == 5432
assert url.database == "auth"
assert url.username == "auth_app"
def test_username_is_quoted_too(clean_env: None) -> None:
url = make_url(
Settings(
auth_db_password=SecretStr("pw"), auth_db_user="odd:user@name"
).resolved_auth_database_url
)
assert url.username == "odd:user@name"
assert url.host == "postgres"
def test_password_whitespace_is_preserved_not_stripped(clean_env: None) -> None:
"""Ведущий/хвостовой пробел может быть частью настоящего пароля — не режем."""
url = make_url(Settings(auth_db_password=SecretStr(" pw ")).resolved_auth_database_url)
assert url.password == " pw "
def test_blank_password_means_not_configured(clean_env: None) -> None:
"""Пробельная строка — опечатка в .env, а не пароль: «не сконфигурировано»."""
assert Settings(auth_db_password=SecretStr(" ")).resolved_auth_database_url == ""
assert Settings(auth_db_password=SecretStr("")).resolved_auth_database_url == ""
def test_blank_parts_fall_back_to_defaults(clean_env: None) -> None:
"""`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`.
Сценарий бытовой: ops копирует блок AUTH_DB_* целиком и заполняет только пароль.
"""
url = make_url(
Settings(
auth_db_password=SecretStr("pw"),
auth_db_host=" ",
auth_db_name="",
auth_db_user=" ",
).resolved_auth_database_url
)
assert (url.host, url.database, url.username) == ("postgres", "auth", "auth_app")
@pytest.mark.parametrize("blank", ["", " "])
def test_blank_port_does_not_break_import(clean_env: None, blank: str) -> None:
"""`AUTH_DB_PORT=` → дефолт, а НЕ ValidationError.
`settings = Settings()` выполняется на уровне модуля: падение здесь уводило бы
контейнер в restart-loop причём в дефолтном режиме, где к БД `auth` не идёт ни
одного обращения.
"""
assert Settings(auth_db_port=blank).auth_db_port == 5432
def test_non_blank_garbage_port_still_fails(clean_env: None) -> None:
"""`AUTH_DB_PORT=abc` — опечатка со смыслом, её глушить нельзя."""
with pytest.raises(ValueError):
Settings(auth_db_port="abc")
def test_password_is_not_printed_by_repr_or_dump(clean_env: None) -> None:
"""SecretStr: пароль не утекает в `repr(settings)` / `model_dump()`.
Сегодня их никто не рендерит, но появиться такой рендер (лог старта, /debug) может
тихо а рядом с обычным str-полем это была бы утечка секрета в открытый лог.
"""
fresh = Settings(auth_db_password=SecretStr("s3cret"))
assert "s3cret" not in repr(fresh)
assert "s3cret" not in str(fresh.model_dump())
assert fresh.auth_db_password.get_secret_value() == "s3cret"
# ---------------------------------------------------------------------------
# 3. auth_db: ленивость, кеш, ошибки
# ---------------------------------------------------------------------------
def test_import_without_any_auth_env_does_not_build_engine() -> None:
"""Импорт в чистом окружении: ошибки нет, engine не создан, DSN пуст.
Проверяется отдельным процессом, потому что в текущем модуль импортирован давно и
любое утверждение про «на импорте» было бы про уже случившийся импорт. Это отличие
от `app.core.db`, где engine создаётся в теле модуля: сделай мы так же, приложение
падало бы на старте везде, где реестр не сконфигурирован локально, в pytest, на
любом стенде. Ровно тот контракт, который держит дефолтное поведение прода.
"""
env = {k: v for k, v in os.environ.items() if k not in _AUTH_ENV_VARS}
code = (
"from app.core import auth_db\n"
"from app.core.config import settings\n"
"print(auth_db._engine, repr(settings.resolved_auth_database_url), "
"settings.auth_session_enabled)\n"
)
proc = subprocess.run(
[sys.executable, "-c", code],
cwd=Path(__file__).resolve().parents[1],
env=env,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip() == "None '' False"
def test_unconfigured_registry_raises_with_actionable_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пустой DSN → явная ошибка с инструкцией, а не «сессия не найдена»."""
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
with pytest.raises(AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
assert "AUTH_MODE" in str(excinfo.value)
assert "AUTH_DB_PASSWORD" in str(excinfo.value)
@pytest.mark.parametrize(
"broken",
[
"not-a-dsn-at-all",
# «Почти URL»: разбор доходит до int(port) и падает, унося в текст ошибки
# кусок пароля, съехавший на позицию порта.
"postgresql+psycopg://u:pa@ss@host:wo/auth",
],
)
def test_malformed_dsn_does_not_leak_into_the_error(
monkeypatch: pytest.MonkeyPatch, broken: str
) -> None:
"""Битый DSN → своя ошибка БЕЗ самого DSN и без исходного traceback.
Текст ошибки SQLAlchemy цитирует строку целиком, а в ней пароль роли auth_app.
`from None` обязателен: без него исходная ошибка печаталась бы в traceback как
«During handling of the above exception...» то есть пароль всё равно оказался бы
в логе.
"""
monkeypatch.setattr(settings, "auth_database_url", broken)
with pytest.raises(AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
assert broken not in str(excinfo.value)
assert "pa@ss" not in str(excinfo.value)
assert excinfo.value.__suppress_context__ is True
assert excinfo.value.__cause__ is None
def test_engine_is_built_once_and_reused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
first = auth_db.get_auth_engine()
second = auth_db.get_auth_engine()
assert first is second
assert auth_db.get_auth_session_factory().kw["bind"] is first
assert first.url.database == "auth"
def test_reset_drops_the_cached_engine(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
first = auth_db.get_auth_engine()
auth_db.reset_auth_db()
assert auth_db._engine is None
assert auth_db.get_auth_engine() is not first
# ---------------------------------------------------------------------------
# require_auth_db_configured — fail-fast на старте (lifespan)
# ---------------------------------------------------------------------------
def test_startup_check_is_noop_while_flag_is_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""🔒 Дефолт: пустой DSN на старте — не ошибка, и engine не создаётся.
Ровно то, что произойдёт на проде сразу после мержа этого PR.
"""
monkeypatch.setattr(settings, "auth_mode", "legacy")
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
auth_db.require_auth_db_configured()
assert auth_db._engine is None
def test_startup_check_fails_fast_when_enabled_without_dsn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Флаг включили, DSN не задали → контейнер не стартует.
Иначе пустой DSN ловил бы `except` в guard'е, и сломанная конфигурация выглядела бы
как «ни у кого нет сессии» сутками, при живом приложении и 200-х в ответах.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
with pytest.raises(AuthDatabaseNotConfiguredError):
auth_db.require_auth_db_configured()
def test_startup_check_builds_engine_and_probes_connection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Режим включён и реестр отвечает → engine готов ещё до первого запроса.
Проба соединения подменена: поднимать Postgres ради этого теста незачем, важно, что
она вызывается ИМЕННО на том engine, который останется закешированным.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
probed: list[object] = []
monkeypatch.setattr(auth_db, "_probe_connection", probed.append)
auth_db.require_auth_db_configured()
assert auth_db._engine is not None
assert probed == [auth_db._engine]
def test_startup_check_fails_when_dsn_parses_but_connection_does_not(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""🔴 Смысл проверки: DSN разобрался — это ещё НЕ «реестр рабочий».
`create_engine` к серверу не ходит, поэтому одна лишь сборка engine отлавливала бы
ровно два случая (DSN пуст / не парсится). Весь вероятный класс ошибок неверный
AUTH_DB_PASSWORD, опечатка в хосте, не созданная БД `auth`, отозванная роль
auth_app, нет сети проходил бы мимо, контейнер стартовал бы зелёным, `/health`
отвечал бы 200, а каждый запрос с кукой молча деградировал бы на легаси-заголовок.
Сутками. Ровно то, что комментарий в app/main.py обещает НЕ допускать.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
def _refused(_engine: object) -> None:
raise OSError("connection to server at 127.0.0.1, port 1 failed: Connection refused")
monkeypatch.setattr(auth_db, "_probe_connection", _refused)
with pytest.raises(AuthDatabaseUnreachableError) as excinfo:
auth_db.require_auth_db_configured()
# Причина сохранена в цепочке — ради неё проверка и делается; DSN (в нём пароль) в
# наш текст не подставляется.
assert isinstance(excinfo.value.__cause__, OSError)
assert "AUTH_MODE" in str(excinfo.value)
assert _OFFLINE_DSN not in str(excinfo.value)
def test_startup_check_does_not_probe_while_flag_is_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""🔒 legacy: ни коннекта, ни пробы — даже если DSN задан и валиден."""
monkeypatch.setattr(settings, "auth_mode", "legacy")
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
def _boom(_engine: object) -> None:
raise AssertionError("в режиме legacy соединение с реестром недопустимо")
monkeypatch.setattr(auth_db, "_probe_connection", _boom)
auth_db.require_auth_db_configured()
assert auth_db._engine is None
def test_engine_has_short_timeouts(monkeypatch: pytest.MonkeyPatch) -> None:
"""Реестр — не критический путь: его сбой обязан деградировать за секунды.
Без `connect_timeout` дропнутые SYN (фаервол молча глотает пакеты) держали бы
попытку до TCP-таймаута ОС на Linux ~130 с, и так на КАЖДОМ checkout'е, потому
что включён `pool_pre_ping`. `pool_timeout` по дефолту 30 с в auth-пути столько
ждать свободный коннект незачем.
"""
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
captured: dict[str, object] = {}
real_create_engine = auth_db.create_engine
def _spy(dsn: str, **kwargs: object) -> object:
captured.update(kwargs)
return real_create_engine(dsn, **kwargs) # type: ignore[arg-type]
monkeypatch.setattr(auth_db, "create_engine", _spy)
auth_db.get_auth_engine()
assert captured["pool_timeout"] == 3
assert captured["pool_pre_ping"] is True
connect_args = captured["connect_args"]
assert isinstance(connect_args, dict)
assert connect_args["connect_timeout"] == 3
assert "statement_timeout=3000" in connect_args["options"]

View file

@ -0,0 +1,861 @@
"""Dual-mode `rbac_guard` «Птицы» — эпик «единый вход», стадия 3 (тесты).
Что здесь проверяется и почему именно так.
ТЕСТИРУЕТСЯ НАСТОЯЩИЙ `app.main.rbac_guard`, а не его копия. `app.middleware("http")`
у Starlette возвращает саму функцию (декоратор регистрирует dispatch и отдаёт `func`),
поэтому middleware вызывается напрямую: `await rbac_guard(request, call_next)`. Это
принципиально в отличие от `tests/test_rbac.py`, где живёт РУЧНАЯ КОПИЯ guard'а
(она заведена, чтобы не тянуть тяжёлые импорты, и ценой этого расходится с прод-кодом
при каждой правке). Главный тест этого файла про подделку заголовка, то есть про
безопасность; проверять безопасность на копии нельзя, копия не деплоится.
Почему не через `TestClient(app)`: `rbac_guard` первой строкой уходит в
test-mode bypass при `settings.testing=True`, а conftest.py ставит этот флаг
глобально (иначе весь остальной сьют получал бы 401). Прямой вызов middleware
позволяет снять именно этот флаг (monkeypatch, см. `_no_test_bypass`) и получить
прод-поведение guard'а целиком: и session-ветку, и легаси-ветку, и 401/403.
Как проверяется «downstream видит нужного юзера». `_propagate_authenticated_user`
перезаписывает заголовок в `request.scope["headers"]`; scope прокинут по ссылке через
весь ASGI-стек, и следующий слой (audit-middleware, роутер) строит поверх него СВОЙ
`Request`. Дублёр `_Downstream` делает ровно это `Request(request.scope)` то есть
видит заголовок так же, как одиннадцать мест бэкенда, читающих его напрямую мимо
guard'а (перечислены в докстринге `_propagate_authenticated_user`).
БД `auth` здесь не поднимается: подменяется `app.core.auth_db.auth_session` (сам резолв
сессии `get_session_user` прогоняется НАСТОЯЩИЙ, чтобы «истекла»/«не active»
проверялись кодом, а не заглушкой). Юнит-тесты самого резолва в
`tests/test_auth_session_service.py`, конфигурация DSN в `tests/test_auth_db.py`.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from fastapi import Request
from fastapi.responses import JSONResponse, Response
import app.main as app_main
from app.core import auth as auth_mod
from app.core import auth_db
from app.core.config import settings
from app.main import rbac_guard
# Логины из auth/roles.yaml (см. tests/test_rbac.py::test_get_role_known_users):
_ADMIN_LOGIN = "admin" # role=admin
_PILOT_LOGIN = "user1" # role=pilot
_NOT_IN_ROLES_YAML = "ghost" # роли нет вообще → 403 на всё
_VALID_TOKEN = "tok-valid"
_EXPIRED_TOKEN = "tok-expired"
_UNKNOWN_TOKEN = "tok-never-issued"
# ---------------------------------------------------------------------------
# Дублёры
# ---------------------------------------------------------------------------
@dataclass
class _Row:
"""Строка ответа SELECT'а из `app/services/auth_session.py` (4 колонки)."""
expires_at: datetime | None
last_seen_at: datetime | None
username: str
access_state: str
class _FetchOne:
def __init__(self, row: _Row | None) -> None:
self._row = row
def fetchone(self) -> _Row | None:
return self._row
# Форма запросов к реестру. Без этих проверок дублёр диспетчеризует по одному лишь
# `startswith`, и тела SQL не покрыты ВООБЩЕ: мутационный прогон показал, что
# `sessions`→`sessionz`, `users`→`userz`, `s.token`→`s.tokenX`, `last_seen_at`→
# `last_seen_atX` не роняли ни одного теста. Настоящего Postgres в сьюте нет, а цена
# опечатки/дрейфа схемы здесь высокая: не 500, а «ни у кого нет сессии» с тихим
# откатом на легаси-заголовок (после снятия popup'а — локаут всех).
_SELECT_MUST_CONTAIN = (
"FROM sessions s",
"JOIN users u ON u.id = s.user_id",
"WHERE s.token = :token",
# Срок годности отсекается часами БД — теми же, которыми UPDATE ниже пишет
# expires_at. Питоновская проверка остаётся вторым поясом.
"AND s.expires_at > now()",
"s.expires_at",
"s.last_seen_at",
"u.username",
"u.access_state",
)
_UPDATE_MUST_CONTAIN = (
"UPDATE sessions",
"last_seen_at = now()",
# Обе колонки одним UPDATE: продлевай «Птица» только last_seen_at — человек,
# работающий весь день в ней одной, был бы разлогинен по expires_at.
"expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))",
"WHERE token = :token",
)
def _assert_select_shape(sql: str) -> None:
for fragment in _SELECT_MUST_CONTAIN:
assert fragment in sql, f"SELECT к БД auth потерял {fragment!r}: {sql}"
def _assert_update_shape(sql: str) -> None:
for fragment in _UPDATE_MUST_CONTAIN:
assert fragment in sql, f"UPDATE к БД auth потерял {fragment!r}: {sql}"
class FakeAuthDb:
"""Дублёр сессии SQLAlchemy к БД `auth`: понимает ровно два запроса модуля.
Считает обращения (`select_tokens`, `updates`) по ним тесты доказывают не только
результат, но и что запрос вообще был/не был сделан.
"""
def __init__(self, rows: dict[str, _Row] | None = None, *, fail_refresh: bool = False) -> None:
self.rows: dict[str, _Row] = dict(rows or {})
self.select_tokens: list[str] = []
self.updates: list[dict[str, Any]] = []
self.commits = 0
self.rollbacks = 0
self.closed = False
self.fail_refresh = fail_refresh
def execute(self, clause: Any, params: dict[str, Any]) -> _FetchOne:
sql = " ".join(str(clause).split())
if sql.startswith("SELECT"):
_assert_select_shape(sql)
self.select_tokens.append(params["token"])
return _FetchOne(self.rows.get(params["token"]))
if sql.startswith("UPDATE sessions"):
_assert_update_shape(sql)
if self.fail_refresh:
raise RuntimeError("sessions is read-only on this replica")
self.updates.append(dict(params))
return _FetchOne(None)
raise AssertionError(f"неожиданный SQL к БД auth: {sql}")
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
self.rollbacks += 1
def _install_auth_db(monkeypatch: pytest.MonkeyPatch, db: FakeAuthDb | None) -> None:
"""Подменяет `auth_db.auth_session`. `db=None` → любое обращение к БД падает."""
@contextmanager
def _fake_session() -> Iterator[FakeAuthDb]:
if db is None:
raise RuntimeError("connection to auth registry refused")
yield db
monkeypatch.setattr(auth_db, "auth_session", _fake_session)
class _Downstream:
"""`call_next`: запоминает, каким юзером запрос выглядит для следующего слоя."""
def __init__(self) -> None:
self.calls = 0
self.seen_users: list[str | None] = []
self.seen_header_counts: list[int] = []
async def __call__(self, request: Request) -> Response:
# Именно так заголовок видят 11 прямых читателей: свой Request поверх того же
# scope, который guard уже успел переписать.
downstream = Request(request.scope)
self.calls += 1
self.seen_users.append(downstream.headers.get("X-Authenticated-User"))
self.seen_header_counts.append(
sum(1 for k, _ in request.scope["headers"] if k == b"x-authenticated-user")
)
return JSONResponse({"ok": True})
def _make_request(
path: str,
*,
cookie_token: str | None = None,
header_user: str | None = None,
cookie_name: str | None = None,
header_name: bytes = b"x-authenticated-user",
) -> Request:
"""ASGI-scope запроса. Имена заголовков lowercase — как их отдаёт любой сервер.
`header_name` позволяет подсунуть имя в НЕканоническом регистре: спека ASGI требует
lowercase, но полагаться на неё в фильтре `_propagate_authenticated_user` мы не
хотим (чужой ASGI-слой/харнесс может её нарушить).
"""
headers: list[tuple[bytes, bytes]] = [(b"host", b"gendsgn.ru")]
if cookie_token is not None:
name = cookie_name or settings.session_cookie_name
headers.append((b"cookie", f"{name}={cookie_token}".encode()))
if header_user is not None:
headers.append((header_name, header_user.encode("latin-1")))
return Request(
{
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "GET",
"scheme": "https",
"server": ("gendsgn.ru", 443),
"client": ("203.0.113.7", 51234),
"root_path": "",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": headers,
}
)
async def _run_guard(request: Request) -> tuple[Response, _Downstream]:
downstream = _Downstream()
response = await rbac_guard(request, downstream)
return response, downstream
def _valid_session(username: str, *, access_state: str = "active") -> _Row:
now = datetime.now(UTC)
return _Row(
expires_at=now + timedelta(days=7),
last_seen_at=now - timedelta(seconds=30), # свежее 5 минут → без UPDATE
username=username,
access_state=access_state,
)
# ---------------------------------------------------------------------------
# Фикстуры
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_auth_cache() -> None:
"""Свежий YAML-кэш ролей на каждый тест (как в tests/test_rbac.py)."""
auth_mod.reset_cache_for_tests()
@pytest.fixture(autouse=True)
def _reset_registry_throttle() -> Iterator[None]:
"""Окно троттлинга алерта «реестр не отвечает» — модульное состояние app.main.
Без сброса первый же тест, поймавший сбой реестра, глушил бы ERROR у всех
следующих в течение минуты, и они краснели/зеленели бы в зависимости от порядка
и скорости прогона.
"""
app_main._reset_registry_failure_throttle()
yield
app_main._reset_registry_failure_throttle()
@pytest.fixture(autouse=True)
def _no_test_bypass(monkeypatch: pytest.MonkeyPatch) -> None:
"""Снимает test-mode bypass: без этого guard возвращает call_next первой строкой.
conftest.py ставит `settings.testing = True` глобально; monkeypatch вернёт его
обратно после каждого теста, так что остальной сьют не затронут.
"""
monkeypatch.setattr(settings, "testing", False)
@pytest.fixture
def resolve_spy(monkeypatch: pytest.MonkeyPatch) -> list[str | None]:
"""Считает вызовы `resolve_session_token` из app.main, не подменяя его логику.
Нужен, чтобы доказывать НЕ-обращения: «флаг выключен к реестру не ходим»,
«публичный путь к реестру не ходим».
"""
calls: list[str | None] = []
real = app_main.resolve_session_token
def _spy(token: str | None) -> Any:
calls.append(token)
return real(token)
monkeypatch.setattr(app_main, "resolve_session_token", _spy)
return calls
@pytest.fixture
def no_engine_build(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ломает создание engine БД `auth`: тест покраснеет, если его вообще строят."""
def _boom() -> tuple[Any, Any]:
raise AssertionError("engine БД `auth` не должен создаваться в этом сценарии")
monkeypatch.setattr(auth_db, "_build", _boom)
# ---------------------------------------------------------------------------
# ФЛАГ ВЫКЛЮЧЕН (дефолт) — прод обязан вести себя ровно как до эпика
# ---------------------------------------------------------------------------
async def test_flag_is_off_by_default() -> None:
"""Дефолт синглтона settings — режим legacy. Весь файл ниже опирается на это."""
assert settings.auth_mode == "legacy"
assert settings.auth_session_enabled is False
async def test_flag_off_legacy_header_still_works(
monkeypatch: pytest.MonkeyPatch, resolve_spy: list[str | None], no_engine_build: None
) -> None:
"""Сегодняшний путь (Caddy basic_auth → X-Authenticated-User) не изменился."""
monkeypatch.setattr(settings, "auth_mode", "legacy")
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN))
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
assert resolve_spy == []
async def test_flag_off_ignores_session_cookie_and_never_touches_registry(
monkeypatch: pytest.MonkeyPatch, resolve_spy: list[str | None], no_engine_build: None
) -> None:
"""🔒 Инвариант PR: при выключенном флаге кука не читается, к БД `auth` не идём.
Валидная кука + нет легаси-заголовка 401, как сегодня у любого запроса мимо
Caddy. `resolve_spy`/`no_engine_build` доказывают, что дело не в «не нашли
сессию», а в том, что резолв вообще не запускался и engine не строился.
"""
monkeypatch.setattr(settings, "auth_mode", "legacy")
_install_auth_db(monkeypatch, FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)}))
response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 401
assert downstream.calls == 0
assert resolve_spy == []
async def test_flag_off_unknown_user_still_403(monkeypatch: pytest.MonkeyPatch) -> None:
"""Легаси-ветка целиком: юзер не в roles.yaml → 403 «user not in roles config»."""
monkeypatch.setattr(settings, "auth_mode", "legacy")
response, downstream = await _run_guard(
_make_request("/api/v1/me", header_user=_NOT_IN_ROLES_YAML)
)
assert response.status_code == 403
assert downstream.calls == 0
# ---------------------------------------------------------------------------
# ФЛАГ ВКЛЮЧЁН — сессионная кука как источник личности
# ---------------------------------------------------------------------------
@pytest.fixture
def session_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_mode", "dual")
async def test_valid_cookie_grants_access_without_any_header(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Валидная кука пускает — легаси-заголовка при этом нет вовсе."""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 200
assert downstream.seen_users == [_PILOT_LOGIN]
assert db.select_tokens == [_VALID_TOKEN]
async def test_expired_session_cookie_does_not_grant_access(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Истёкшая сессия = сессии нет: без легаси-заголовка это 401."""
now = datetime.now(UTC)
db = FakeAuthDb(
{
_EXPIRED_TOKEN: _Row(
expires_at=now - timedelta(seconds=1),
last_seen_at=now - timedelta(days=1),
username=_ADMIN_LOGIN,
access_state="active",
)
}
)
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_EXPIRED_TOKEN)
)
assert response.status_code == 401
assert downstream.calls == 0
# Истёкшая сессия не продлевается sliding-refresh'ем — иначе она была бы вечной.
assert db.updates == []
@pytest.mark.parametrize("access_state", ["disabled", "trial_expired", "some_future_state"])
async def test_non_active_access_state_does_not_grant_access(
monkeypatch: pytest.MonkeyPatch, session_on: None, access_state: str
) -> None:
"""Блокировка в реестре действует НЕМЕДЛЕННО, не дожидаясь expires_at.
`some_future_state` состояние, добавленное миграцией раньше кода: fail-closed
(`to_access_state` disabled), а не «раз не disabled, значит пускаем».
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN, access_state=access_state)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 401
assert downstream.calls == 0
assert db.updates == []
async def test_session_user_missing_from_roles_yaml_is_403(
monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Есть в реестре, нет в auth/roles.yaml → 403 + отдельное сообщение о рассинхроне.
Реестр отвечает «кто ты», roles.yaml «что тебе можно»; человек, заведённый только
в реестре, не получает доступ по умолчанию.
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session("brand_new_hire")})
_install_auth_db(monkeypatch, db)
with caplog.at_level(logging.WARNING, logger="app.main"):
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)
)
assert response.status_code == 403
assert downstream.calls == 0
assert any("roles.yaml" in r.getMessage() for r in caplog.records)
# ---------------------------------------------------------------------------
# 🔴 ГЛАВНОЕ: подделка X-Authenticated-User при валидной куке
# ---------------------------------------------------------------------------
async def test_valid_cookie_overrides_client_supplied_header(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""🔴 Кука выигрывает у присланного клиентом заголовка — downstream видит ВЛАДЕЛЬЦА КУКИ.
Сценарий: у человека есть валидная сессия (`user1`, pilot), и он вручную добавляет
к запросу `X-Authenticated-User: admin`. На проде Caddy шлёт этот заголовок на
каждый запрос, так что «поставить только если отсутствует» здесь не сработало бы:
заголовок присутствует ВСЕГДА, и любой из одиннадцати прямых читателей (аудит,
/me, created_by в insights/own-projects, forecast/analyze) увидел бы подделку.
Проверяем оба следствия перезаписи: значение владелец куки, и заголовок в scope
РОВНО ОДИН (append без фильтра оставил бы два, а `headers.get` вернул бы первый
то есть подделанный).
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 200
assert downstream.seen_users == [_PILOT_LOGIN], "downstream увидел подделанный заголовок"
assert downstream.seen_header_counts == [1], "в scope осталось два X-Authenticated-User"
async def test_forged_admin_header_cannot_escalate_to_admin_api(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""🔴 Та же подделка на admin-эндпоинте: роль берётся от владельца куки → 403.
Это тест на ЭСКАЛАЦИЮ ПРИВИЛЕГИЙ, а не на атрибуцию, и он ловит другую поломку,
чем тест выше. Проверено мутацией: подмена перезаписи заголовка на append его НЕ
красит guard решает по локальной переменной `username`, уже взятой из сессии.
Покраснеет он от поломки ПОРЯДКА: «сначала заголовок, потом кука» или повторное
чтение `request.headers` после резолва тогда pilot с подделанным `admin` вошёл
бы в /api/v1/admin/*. Оба теста нужны: один держит downstream, другой сам guard.
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request(
"/api/v1/admin/scrape/status", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN
)
)
assert response.status_code == 403
assert response.body == b'{"detail":"admin only"}'
assert downstream.calls == 0
async def test_cookie_owner_wins_even_when_forged_header_is_unknown_user(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Обратная сторона: мусор в заголовке не мешает владельцу валидной куки войти.
Пинует порядок «кука заголовок»: если бы заголовок проверялся первым, `ghost`
дал бы 403 человеку с законной сессией.
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request(
"/api/v1/admin/scrape/status",
cookie_token=_VALID_TOKEN,
header_user=_NOT_IN_ROLES_YAML,
)
)
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
# ---------------------------------------------------------------------------
# Dual-mode: нет куки / кука не резолвится → легаси-заголовок
# ---------------------------------------------------------------------------
async def test_no_cookie_falls_back_to_legacy_header(
monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None]
) -> None:
"""Флаг включён, куки нет — работает заголовок, и в БД `auth` не идёт ни запроса."""
_install_auth_db(monkeypatch, None) # любое обращение к реестру → RuntimeError
response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN))
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
assert resolve_spy == [], "куки нет — резолвить нечего, коннект открывать незачем"
async def test_unknown_token_falls_back_to_legacy_header(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Кука есть, сессии в реестре нет (протухла/отозвана) → легаси-путь, не отказ.
Пока стоит popup, это ровно тот же уровень доступа, что и сегодня; отказывать
здесь значило бы ломать вход людям со старой кукой в браузере.
"""
db = FakeAuthDb() # пусто: токен не найден
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_UNKNOWN_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
assert db.select_tokens == [_UNKNOWN_TOKEN]
async def test_foreign_cookie_name_is_not_a_session(
monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None]
) -> None:
"""Чужая кука (другое имя) сессией не считается — читаем только session_cookie_name."""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token="whatever", cookie_name="ym_uid")
)
assert response.status_code == 401
assert downstream.calls == 0
assert resolve_spy == []
async def test_no_cookie_no_header_is_401(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Ни куки, ни заголовка → 401 с прежним текстом (его читает фронт)."""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request("/api/v1/me"))
assert response.status_code == 401
assert b"no authenticated user" in response.body
assert downstream.calls == 0
# ---------------------------------------------------------------------------
# Публичные пути
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path", sorted(app_main._PUBLIC_PATHS))
async def test_public_paths_need_nothing_and_touch_no_registry(
monkeypatch: pytest.MonkeyPatch,
session_on: None,
resolve_spy: list[str | None],
no_engine_build: None,
path: str,
) -> None:
"""/health и прочие публичные пути — без куки, без заголовка, без коннекта к `auth`.
Параметризация по самому `_PUBLIC_PATHS`: добавят путь в список он проверится.
"""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request(path))
assert response.status_code == 200
assert downstream.calls == 1
assert resolve_spy == []
async def test_public_path_with_cookie_still_skips_registry(
monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None]
) -> None:
"""Публичный путь + кука в браузере → всё равно ни одного запроса к реестру."""
_install_auth_db(monkeypatch, FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)}))
response, _ = await _run_guard(_make_request("/health", cookie_token=_VALID_TOKEN))
assert response.status_code == 200
assert resolve_spy == []
# ---------------------------------------------------------------------------
# Сбой БД `auth` при резолве
# ---------------------------------------------------------------------------
async def test_registry_failure_does_not_silently_admit_cookie_owner(
monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Реестр упал → 401 (нет легаси-заголовка) + ERROR с traceback, а не тихий проход.
Два независимых требования:
1) владелец куки НЕ входит «на всякий случай» упавший резолв не даёт личности;
2) событие громкое: `logger.exception` уровня ERROR уезжает в GlitchTip
(LoggingIntegration event_level=ERROR), т.е. это алерт, а не строка в логе.
"""
_install_auth_db(monkeypatch, None)
with caplog.at_level(logging.ERROR, logger="app.main"):
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)
)
assert response.status_code == 401
assert downstream.calls == 0
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1, "сбой реестра обязан быть ровно одним ERROR-событием"
assert errors[0].exc_info is not None, "нужен traceback: без него алерт бесполезен"
async def test_registry_failure_degrades_to_legacy_while_popup_is_up(
monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Осознанная переходная деградация: сломанный реестр → сегодняшний путь + ERROR.
Пока Caddy basic_auth стоит перед бэкендом, легаси-заголовок защищён ровно тем же,
чем защищён весь продукт сегодня, и класть «Птицу» целиком (503) из-за проблемы
реестра незачем.
Этот тест маркер долга, а не одобрение поведения навсегда. Последний PR эпика
снимает popup; вместе с ним деградация обязана уйти (у «Меры» это auth_mode=db_only),
иначе заголовок станет полностью клиентским. Тест тогда переписывается на отказ.
"""
_install_auth_db(monkeypatch, None)
with caplog.at_level(logging.ERROR, logger="app.main"):
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
assert [r for r in caplog.records if r.levelno >= logging.ERROR]
# ---------------------------------------------------------------------------
# Test-mode bypass остаётся выключателем ВСЕГО guard'а
# ---------------------------------------------------------------------------
async def test_testing_bypass_disables_session_branch_too(
monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None]
) -> None:
"""`settings.testing=True` отключает и session-ветку — сознательно, не по недосмотру.
Промежуточного состояния «личность резолвим, а 401/403 не применяем» нет ни в одном
реальном режиме; поэтому весь остальной сьют (conftest ставит testing=True) не
начинает вдруг ходить в БД `auth`.
"""
monkeypatch.setattr(settings, "testing", True)
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN], "bypass не должен переписывать заголовок"
assert resolve_spy == []
async def test_forged_header_in_mixed_case_is_replaced_not_duplicated(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Заголовок в НЕканоническом регистре тоже вытесняется, а не остаётся вторым.
По спеке ASGI имена заголовков в scope всегда lowercase, и uvicorn её соблюдает
но `_propagate_authenticated_user` на это не полагается. Если бы фильтр сравнивал
сырые байты, в scope осталась бы ВТОРАЯ запись `X-Authenticated-User: admin` рядом
с нашей. Эксплуатируемой дыры это не давало (`Headers.get` лоуэркейсит искомый
ключ, но не хранимый, поэтому смешанный регистр не матчится никогда), но состояние
«две записи с одним именем» ложное по построению и в чужом ASGI-слое, который
регистр нормализует, оно стало бы подделкой.
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
request = _make_request(
"/api/v1/me",
cookie_token=_VALID_TOKEN,
header_user=_ADMIN_LOGIN,
header_name=b"X-Authenticated-User",
)
response, downstream = await _run_guard(request)
assert response.status_code == 200
assert downstream.seen_users == [_PILOT_LOGIN]
names = [k for k, _ in request.scope["headers"] if k.lower() == b"x-authenticated-user"]
assert names == [b"x-authenticated-user"], "подделка осталась в scope вторым заголовком"
async def test_registry_failure_alert_is_throttled(
monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Лежащий реестр даёт ОДИН ERROR на окно, остальное — WARNING без traceback.
Guard резолвит сессию на каждом non-public запросе с кукой, а ERROR уезжает
событием в GlitchTip (LoggingIntegration event_level=ERROR). Без троттлинга сбой
реестра выжигал бы квоту за минуты и настоящие ошибки этого же периода терялись
бы вместе с ней. Факт продолжающегося сбоя при этом остаётся видимым в логах.
"""
_install_auth_db(monkeypatch, None)
with caplog.at_level(logging.WARNING, logger="app.main"):
for _ in range(3):
response, _ = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 401
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(errors) == 1, "второй и третий сбой обязаны быть подавлены троттлингом"
assert errors[0].exc_info is not None
assert len(warnings) == 2, "подавленные сбои всё равно обязаны быть видны в логе"
assert all(w.exc_info is None for w in warnings)
# ---------------------------------------------------------------------------
# AUTH_MODE=db_only — конечное состояние эпика: легаси-ветка НЕДОСТИЖИМА
# ---------------------------------------------------------------------------
@pytest.fixture
def db_only(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_mode", "db_only")
async def test_db_only_ignores_legacy_header_completely(
monkeypatch: pytest.MonkeyPatch, db_only: None, resolve_spy: list[str | None]
) -> None:
"""🔴 Ради этого режим и заведён: `X-Authenticated-User` больше не пускает никого.
Этот режим включается тем же PR, который снимает `basic_auth` + `header_up` из
Caddy, то есть делает заголовок полностью клиентским. Пройди `curl -H
'X-Authenticated-User: admin'` здесь это был бы полный обход аутентификации.
"""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN))
assert response.status_code == 401
assert b"valid session required" in response.body
assert downstream.calls == 0
assert resolve_spy == [], "куки нет — резолвить нечего"
async def test_db_only_rejects_when_registry_is_down(
monkeypatch: pytest.MonkeyPatch, db_only: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Сбой реестра в db_only = отказ, а не деградация на заголовок.
Тот же вход, что в `test_registry_failure_degrades_to_legacy_while_popup_is_up`
(кука + заголовок + лежащий реестр), но исход противоположный. Пара тестов и есть
механическая защита: удалить легаси-фолбэк забудут этот тест покраснеет, если
db_only начнёт вести себя как dual.
"""
_install_auth_db(monkeypatch, None)
with caplog.at_level(logging.ERROR, logger="app.main"):
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 401
assert downstream.calls == 0
assert [r for r in caplog.records if r.levelno >= logging.ERROR]
@pytest.mark.parametrize("access_state", ["disabled", "trial_expired"])
async def test_db_only_blocked_account_cannot_fall_back_to_header(
monkeypatch: pytest.MonkeyPatch, db_only: None, access_state: str
) -> None:
"""Заблокированный в реестре не добирает доступ подделанным заголовком."""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN, access_state=access_state)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 401
assert downstream.calls == 0
async def test_db_only_admits_valid_session(monkeypatch: pytest.MonkeyPatch, db_only: None) -> None:
"""Валидная сессия работает и в db_only — режим убирает фолбэк, а не вход."""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 200
assert downstream.seen_users == [_PILOT_LOGIN]
async def test_db_only_keeps_public_paths_open(
monkeypatch: pytest.MonkeyPatch, db_only: None, no_engine_build: None
) -> None:
"""/health и прочие публичные пути остаются публичными — иначе упадёт healthcheck."""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request("/health"))
assert response.status_code == 200
assert downstream.calls == 1

View file

@ -0,0 +1,305 @@
"""Резолв сессии общего реестра — `app/services/auth_session.py` (эпик «единый вход»).
Слой ниже guard'а: «что считать валидной сессией» и «когда продлевать». Через guard
эти правила проверяются end-to-end в `tests/test_auth_session_guard.py`; здесь
поштучно, включая ветки, до которых из guard'а дотянуться дорого (sliding refresh,
сбой продления, исключения БД).
Дублёр сессии БД (`FakeAuthDb`) намеренно ОДИН на оба файла и живёт в guard-тестах:
разъехавшиеся двойники типовой способ получить два зелёных теста при одном сломанном
поведении. Прецедент кросс-импорта внутри пакета tests `tests/integration/*`.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
import pytest
from pydantic import SecretStr
from app.core import auth_db
from app.core.auth_db import AuthDatabaseNotConfiguredError
from app.core.config import settings
from app.services import auth_session as svc
from app.services.auth_session import AccessState, SessionUser, get_session_user, to_access_state
from tests.test_auth_session_guard import FakeAuthDb, _install_auth_db, _Row
_TOKEN = "tok-1"
_USER = "user1"
def _row(
*,
expires_in: timedelta = timedelta(days=7),
last_seen_ago: timedelta | None = timedelta(seconds=30),
username: str = _USER,
access_state: str = "active",
) -> _Row:
now = datetime.now(UTC)
return _Row(
expires_at=now + expires_in,
last_seen_at=None if last_seen_ago is None else now - last_seen_ago,
username=username,
access_state=access_state,
)
# ---------------------------------------------------------------------------
# AccessState / to_access_state — fail-closed
# ---------------------------------------------------------------------------
def test_only_active_can_sign_in() -> None:
assert AccessState.ACTIVE.can_sign_in is True
assert AccessState.TRIAL_EXPIRED.can_sign_in is False
assert AccessState.DISABLED.can_sign_in is False
def test_to_access_state_known_values() -> None:
assert to_access_state("active") is AccessState.ACTIVE
assert to_access_state("trial_expired") is AccessState.TRIAL_EXPIRED
assert to_access_state("disabled") is AccessState.DISABLED
@pytest.mark.parametrize("value", ["frozen", "", None, 42])
def test_to_access_state_unknown_is_disabled_with_warning(
value: object, caplog: pytest.LogCaptureFixture
) -> None:
"""Неизвестное/NULL/чужой тип → disabled + WARNING.
Миграции БД `auth` применяет деплой «Птицы», то есть новое состояние может
появиться в базе раньше, чем код о нём узнает. Обратный выбор («не disabled =
пускаем») означал бы, что такая миграция молча раздаёт доступ.
"""
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
assert to_access_state(value) is AccessState.DISABLED
assert caplog.records
# ---------------------------------------------------------------------------
# get_session_user — что считается валидной сессией
# ---------------------------------------------------------------------------
def test_valid_session_resolves_to_user() -> None:
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, _TOKEN) == SessionUser(
username=_USER, access_state=AccessState.ACTIVE
)
assert db.select_tokens == [_TOKEN]
def test_empty_token_short_circuits_without_query() -> None:
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, "") is None
assert db.select_tokens == []
def test_unknown_token_returns_none() -> None:
db = FakeAuthDb()
assert get_session_user(db, "never-issued") is None
assert db.updates == []
def test_expired_session_returns_none_and_is_not_refreshed() -> None:
"""Истёкшая сессия не воскресает sliding-refresh'ем — иначе TTL был бы вечным."""
db = FakeAuthDb(
{_TOKEN: _row(expires_in=timedelta(seconds=-1), last_seen_ago=timedelta(days=1))}
)
assert get_session_user(db, _TOKEN) is None
assert db.updates == []
def test_null_expires_at_returns_none() -> None:
"""`expires_at IS NULL` (колонку ослабили) → сессии нет, а не TypeError в auth-пути."""
row = _row()
row.expires_at = None
db = FakeAuthDb({_TOKEN: row})
assert get_session_user(db, _TOKEN) is None
@pytest.mark.parametrize("access_state", ["disabled", "trial_expired", "unheard_of"])
def test_non_active_user_returns_none_immediately(access_state: str) -> None:
"""Блокировка в реестре бьёт сразу, не дожидаясь expires_at (иначе до 30 дней)."""
db = FakeAuthDb({_TOKEN: _row(access_state=access_state)})
assert get_session_user(db, _TOKEN) is None
assert db.updates == []
# ---------------------------------------------------------------------------
# Sliding refresh
# ---------------------------------------------------------------------------
def test_refresh_skipped_for_recent_session() -> None:
"""Свежий last_seen_at → ни одного UPDATE: иначе каждый API-запрос бил бы в БД."""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(seconds=30))})
assert get_session_user(db, _TOKEN) is not None
assert db.updates == []
assert db.commits == 0
@pytest.mark.parametrize(
"last_seen_ago", [timedelta(minutes=5), timedelta(hours=3), None], ids=["at-5m", "3h", "null"]
)
def test_refresh_extends_after_interval(last_seen_ago: timedelta | None) -> None:
""">= 5 минут (и NULL) → один UPDATE на обе колонки + commit.
TTL берётся из настроек и обязан совпадать с «Мерой»: продлевает сессию тот
продукт, в котором кликнули последним, и срок жизни не должен от этого зависеть.
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=last_seen_ago)})
assert get_session_user(db, _TOKEN) is not None
assert db.updates == [{"ttl_hours": settings.session_ttl_hours, "token": _TOKEN}]
assert db.commits == 1
def test_refresh_uses_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "session_ttl_hours", 12)
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))})
get_session_user(db, _TOKEN)
assert db.updates == [{"ttl_hours": 12, "token": _TOKEN}]
def test_refresh_failure_does_not_block_valid_session(caplog: pytest.LogCaptureFixture) -> None:
"""Продление — best-effort: сбой логируется и откатывается, юзер всё равно валиден.
Иначе read-only реплика или блокировка строки разлогинивала бы всех, у кого
сессия старше пяти минут.
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}, fail_refresh=True)
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
user = get_session_user(db, _TOKEN)
assert user == SessionUser(username=_USER, access_state=AccessState.ACTIVE)
assert db.rollbacks == 1
assert any("sliding refresh failed" in r.getMessage() for r in caplog.records)
# В сообщении не должно быть ни username, ни токена: лог — не место для связки
# «кто именно» + «когда», а разбор идёт по времени.
assert not any(_USER in r.getMessage() or _TOKEN in r.getMessage() for r in caplog.records)
def test_select_failure_is_not_swallowed() -> None:
"""Сбой SELECT'а летит наружу: решение «что делать со сломанным реестром» — не здесь.
Проглоти резолвер ошибку вызывающий получил бы «сессии нет», то есть отказ
выглядел бы как «просто не залогинен», а откат на trusted-header как норма.
"""
db = FakeAuthDb()
def _boom(*_a: object, **_k: object) -> None:
raise RuntimeError("auth registry is down")
db.execute = _boom # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="auth registry is down"):
get_session_user(db, _TOKEN)
# ---------------------------------------------------------------------------
# resolve_session_token — точка входа guard'а
# ---------------------------------------------------------------------------
def test_resolve_returns_none_without_touching_db_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""🔒 Инвариант «выключенный флаг = ни одного коннекта» держится этим модулем.
Он не полагается на то, что вызывающий сам не позовёт резолв: даже с валидным
токеном соединение не открывается.
"""
monkeypatch.setattr(settings, "auth_mode", "legacy")
_install_auth_db(monkeypatch, None) # открытие сессии → RuntimeError
assert svc.resolve_session_token(_TOKEN) is None
@pytest.mark.parametrize("token", [None, ""])
def test_resolve_returns_none_for_empty_token(
monkeypatch: pytest.MonkeyPatch, token: str | None
) -> None:
monkeypatch.setattr(settings, "auth_mode", "dual")
_install_auth_db(monkeypatch, None)
assert svc.resolve_session_token(token) is None
def test_resolve_opens_registry_session_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_mode", "dual")
db = FakeAuthDb({_TOKEN: _row()})
_install_auth_db(monkeypatch, db)
assert svc.resolve_session_token(_TOKEN) == SessionUser(
username=_USER, access_state=AccessState.ACTIVE
)
assert db.select_tokens == [_TOKEN]
def test_resolve_propagates_not_configured_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Флаг включён, DSN пуст → исключение наружу, а не «сессия не найдена».
Тихий None здесь означал бы либо массовый отказ доступа под видом «не залогинен»,
либо (в guard'е) бессрочную раздачу прав в обход реестра. Настоящий `auth_db` не
подменяется проверяется именно связка сервис конфигурация.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
auth_db.reset_auth_db()
try:
with pytest.raises(AuthDatabaseNotConfiguredError):
svc.resolve_session_token(_TOKEN)
finally:
auth_db.reset_auth_db()
def test_expiry_is_also_filtered_by_db_clock() -> None:
"""Срок годности отсекается ЧАСАМИ БД, а не только часами процесса.
Асимметрия, которую это закрывает: решение «жива ли сессия» принимал Python
(`datetime.now(UTC)`), а продление писало `expires_at = now() + interval` часами
СЕРВЕРА. Отставание часов приложения давало бы не «сессия проживёт на дельту
дольше», а необратимое воскрешение: строку, которую БД уже считает мёртвой, Python
пропускал бы, тут же срабатывал sliding-refresh и отодвигал expires_at на полный
TTL от серверного now(). Секунда расхождения +30 дней жизни.
Форма запроса проверяется дублёром (`_assert_select_shape`), поэтому здесь
достаточно одного прохода: потеряется `AND s.expires_at > now()` тест покраснеет.
"""
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, _TOKEN) is not None
def test_rollback_failure_does_not_break_the_resolve(caplog: pytest.LogCaptureFixture) -> None:
"""Сбой самого rollback'а (оборванный коннект) не отменяет валидную сессию.
Иначе «best-effort продление» переставало быть best-effort: исключение улетало бы
из get_session_user наружу, и валидный юзер получал бы вместо доступа ERROR в
GlitchTip и деградацию на легаси-заголовок (а в db_only отказ).
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}, fail_refresh=True)
def _dead_connection() -> None:
raise RuntimeError("server closed the connection unexpectedly")
db.rollback = _dead_connection # type: ignore[method-assign]
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
user = get_session_user(db, _TOKEN)
assert user == SessionUser(username=_USER, access_state=AccessState.ACTIVE)
assert any("rollback" in r.getMessage() for r in caplog.records)

View file

@ -184,10 +184,36 @@ def test_load_ps_35_220_parse_and_match() -> None:
assert first["installed"] == 40.0
assert first["reserve"] == 15.0
assert first["district"] == "Ленинский"
assert first["load_pct"] == 41.0 # доля 0.41 → 41.0%
assert first["asof"] == date(2026, 6, 30)
def test_load_ps_35_220_does_not_write_load_percent() -> None:
"""#2464-B: степень загрузки НЕ уходит в UPDATE и не попадает в load_index.
Раньше значение колонки E писалось как
`load_index = COALESCE(load_index, CAST(:load_pct AS text))`. load_index
категориальная колонка ('open'|'limited'|'closed'|NULL,
data/sql/180_connection_capacity.sql:35): число строкой фронт отбрасывает
в «неизвестно» (classifyLoadIndex), а в power_summary.by_load_index
появлялся бы бакет с именем вроде "41.0".
На проде не стреляло только потому, что load_index заполнен у всех строк
(open 2741 / limited 346 / closed 329, NULL 0 замер верификации 13.08),
и COALESCE не проваливался.
"""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=1)
ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
# Комментарии из SQL убираем: слово load_index встречается в пояснении,
# а проверять надо ИСПОЛНЯЕМЫЙ текст, а не прозу вокруг него.
sql_code = "\n".join(line.split("--", 1)[0] for line in str(db.calls[0][0]).splitlines())
assert "load_index" not in sql_code, sql_code
for _sql, params in db.calls:
assert "load_pct" not in params, params
def test_load_ps_35_220_unmatched_counted() -> None:
"""ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем."""
from datetime import date

View file

@ -9,3 +9,21 @@ def test_health() -> None:
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
def test_health_head_ok_no_body() -> None:
"""HEAD /health — то, что реально шлёт внешний uptime-monitor через Caddy
(`handle /health { reverse_proxy backend:8000 }`, Caddyfile:60), не GET.
Starlette не добавляет HEAD автоматически к `@app.get()` (в отличие от
низкоуровневого `Route(methods=["GET"])`) без явного `@app.head()`
прод-эндпоинт отдаёт 405 на HEAD.
"""
client = TestClient(app)
response = client.head("/health")
assert response.status_code == 200
assert response.content == b""
# RFC 9110 §9.3.2 — заголовки представления (Content-Type) должны совпадать
# с GET; Content-Length допустимо не совпадать (payload header field, MAY
# быть опущен для HEAD).
assert response.headers["content-type"] == "application/json"

View file

@ -42,11 +42,26 @@ def _reset_auth_cache() -> None:
# Test app — копия rbac_guard из app/main.py, чтобы не подтягивать тяжёлые
# импорты (weasyprint, celery worker, ...). Если поведение middleware меняется
# в проде — синхронизируй здесь.
#
# NB: копия воспроизводит ЛЕГАСИ-ВЕТКУ принятия решения (trusted-header) и намеренно
# не знает про сессионную куку общего реестра, добавленную эпиком «единый вход»:
# при AUTH_MODE=legacy (дефолт) прод-guard принимает решение ровно так же, и тесты
# ниже проверяют именно тот режим. Режимы dual/db_only (кука → заголовок, приоритет
# куки над подделанным заголовком, 401/403, публичные пути) покрыты в
# tests/test_auth_session_guard.py — там вызывается НАСТОЯЩИЙ app.main.rbac_guard,
# без копии.
#
# «Ровно так же» — про ЛОГИКУ, не про списки: `_PUBLIC_PATHS` ниже держится
# синхронным с прод-версией руками (расхождение уже случалось — в копии не было
# /api/v1/ping), и никакой механики, которая бы это гарантировала, нет. Прод-список
# параметризован в test_auth_session_guard.py, поэтому его расширение хотя бы там
# проверяется автоматически.
# ---------------------------------------------------------------------------
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
# Синхронно с app.main._PUBLIC_PATHS (там же и /api/v1/ping — он был потерян здесь).
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
def _build_test_app() -> FastAPI:
@ -110,11 +125,24 @@ def client() -> TestClient:
# ---------------------------------------------------------------------------
# Пилотные логины user1..user10 в auth/roles.yaml. user2 — «Брусника»: доступ
# закрыт владельцем продукта 2026-07-30, роль переведена pilot → expired. Это
# ЕДИНСТВЕННОЕ отклонение от «все userN = pilot», и оно ожидаемое; хардкод
# именно здесь, отдельной константой, а не магическим `if` в цикле.
_EXPIRED_PILOT_LOGINS = {"user2": "«Брусника», доступ закрыт 2026-07-30"}
def test_get_role_known_users() -> None:
"""Ловит рассинхрон auth/roles.yaml с ожиданиями теста: roles.yaml лежит вне
`backend/**`, поэтому правка ролей не попадает в paths-filter CI и такой
рассинхрон CI молча пропускает (так и случилось с user2 expired)."""
assert auth_mod.get_role("admin") == "admin"
assert auth_mod.get_role("kopylov") == "pilot"
for n in range(1, 11):
assert auth_mod.get_role(f"user{n}") == "pilot"
login = f"user{n}"
expected = "expired" if login in _EXPIRED_PILOT_LOGINS else "pilot"
why = _EXPIRED_PILOT_LOGINS.get(login, "обычный пилотный логин")
assert auth_mod.get_role(login) == expected, f"{login}: ожидали {expected}{why}"
def test_get_role_unknown_user_raises() -> None:

View file

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

View file

@ -0,0 +1,44 @@
"""Проверка, что сторож пропусков кричит под Actions (#2871)."""
from __future__ import annotations
import types
import tests.conftest as ct
def _run_guard(monkeypatch, capsys, *, ci: bool, observed: set[str]) -> str:
monkeypatch.setattr(ct, "_observed_skips", observed)
monkeypatch.setattr(ct, "_allowed_skips", lambda: set())
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
monkeypatch.delenv("CI", raising=False)
if ci:
monkeypatch.setenv("GITHUB_ACTIONS", "true")
session = types.SimpleNamespace(exitstatus=0)
ct.pytest_sessionfinish(session, 0)
return capsys.readouterr().out, session.exitstatus
def test_guard_emits_error_annotation_under_actions(monkeypatch, capsys) -> None:
out, rc = _run_guard(monkeypatch, capsys, ci=True, observed={"tests/x.py::test_y"})
assert "::error::" in out, "под Actions сторож обязан подниматься в аннотации"
assert "tests/x.py::test_y" in out
assert rc == 1
def test_guard_stays_quiet_locally(monkeypatch, capsys) -> None:
"""Контроль: локально ::error:: не нужен, человеческое сообщение остаётся."""
out, rc = _run_guard(monkeypatch, capsys, ci=False, observed={"tests/x.py::test_y"})
assert "::error::" not in out
assert "НЕУЧТЁННЫЙ ПРОПУСК" in out
assert rc == 1
def test_guard_silent_when_all_skips_declared(monkeypatch, capsys) -> None:
"""Контроль: без незадекларированных пропусков сторож молчит и не роняет."""
monkeypatch.setattr(ct, "_observed_skips", set())
monkeypatch.setattr(ct, "_allowed_skips", lambda: set())
session = types.SimpleNamespace(exitstatus=0)
ct.pytest_sessionfinish(session, 0)
assert capsys.readouterr().out == ""
assert session.exitstatus == 0

View file

@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет:
- resolve_weights: нет user_id и profile_id системные дефолты
- resolve_weights: user_id задан, default-профиль есть его веса
- resolve_weights: profile_id задан его веса
- resolve_weights: профиль не найден системные дефолты (fallback)
- resolve_weights: профиль не найден системные дефолты (fallback) + source != profile
"""
from __future__ import annotations
import logging
from unittest.mock import MagicMock
import pytest
@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None:
"""Оба аргумента None → возвращаются системные веса."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
assert result.source == "system"
# db не должен вызываться вообще
db.execute.assert_not_called()
@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None:
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
db = MagicMock()
result = resolve_weights(db, user_id=None, profile_id=None)
result["school"] = 999.0
result.weights["school"] = 999.0
# Оригинал не изменён
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None:
finally:
wp_module.get_default_profile = original
assert result == custom_weights
assert result.weights == custom_weights
assert result.source == "user_default"
def test_resolve_weights_uses_specific_profile() -> None:
@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None:
finally:
wp_module.get_profile = original
assert result == custom_weights
assert result.weights == custom_weights
assert result.source == "profile"
def test_resolve_weights_profile_not_found_fallback() -> None:
@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None:
wp_module.get_profile = original_get
wp_module.get_default_profile = original_default
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
# #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли
assert result.source == "system"
def test_resolve_weights_empty_profile_weights_fallback() -> None:
@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None:
finally:
wp_module.get_default_profile = original_default
assert result == _SYSTEM_POI_WEIGHTS
assert result.weights == _SYSTEM_POI_WEIGHTS
assert result.source == "system"
def test_resolve_weights_profile_id_without_owner_is_not_profile(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется.
Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 —
source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он
-0.4). Метка обязана быть 'system', а промах попасть в warning.
"""
db = MagicMock()
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
result = resolve_weights(db, user_id=None, profile_id=1)
assert result.source == "system"
assert result.weights == _SYSTEM_POI_WEIGHTS
assert "profile_id=1" in caplog.text
db.execute.assert_not_called() # профиль даже не искали
def test_resolve_weights_missing_profile_falls_to_user_default_not_profile(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль.
Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не
видна. Метка должна сказать 'user_default', а не 'profile'.
"""
import app.services.site_finder.weight_profiles as wp_module
default_profile = _make_profile_mock({"school": 2.0})
db = MagicMock()
original_get = wp_module.get_profile
original_default = wp_module.get_default_profile
wp_module.get_profile = lambda _db, uid, pid: None
wp_module.get_default_profile = lambda _db, uid: default_profile
try:
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
result = resolve_weights(db, user_id="user-1", profile_id=999)
finally:
wp_module.get_profile = original_get
wp_module.get_default_profile = original_default
assert result.source == "user_default"
assert result.weights == {"school": 2.0}
assert "profile_id=999" in caplog.text

View file

@ -220,4 +220,8 @@ COMMENT ON MATERIALIZED VIEW mv_quarter_price_index IS
'Consumer: estimator service (#647-3) reads O(1) by quarter_cad_number. '
'Issue: #760.';
-- C3 (#2583): DROP CASCADE выше уносит грант из 99b — без ре-гранта tradein FDW
-- (quarter_price_index, роль tradein_fdw_reader) ловит permission denied.
GRANT SELECT ON public.mv_quarter_price_index TO tradein_fdw_reader;
COMMIT;

View file

@ -0,0 +1,22 @@
-- 188_regrant_quarter_price_index_fdw.sql
-- C3 (#2583): tradein-эстиматор потерял квартальный индекс 2026-07-05.
--
-- Причина: 179_mv_quarter_price_cadastral_floor.sql делает
-- DROP MATERIALIZED VIEW mv_quarter_price_index CASCADE; CREATE ...
-- DROP+CREATE не сохраняет гранты — GRANT из 99b_grant_quarter_price_index_fdw.sql
-- пропал, и tradein-сторона (foreign table quarter_price_index, роль
-- tradein_fdw_reader) с 05.07 получала:
-- permission denied for materialized view mv_quarter_price_index
--
-- Симптом «не выполняется с 5 июля» — это дата применения 179, а не поломка
-- рефреша: REFRESH MATERIALIZED VIEW (beat: 05:00 МСК 5-го числа) гранты не трогает.
--
-- Применено вживую на прод 2026-08-04 (FDW-чтение проверено: 1894 строки).
-- Этот файл — идемпотентное закрепление. Парный фикс: GRANT дописан в конец 179,
-- чтобы повторное применение 179 больше не теряло грант.
BEGIN;
GRANT SELECT ON public.mv_quarter_price_index TO tradein_fdw_reader;
COMMIT;

View file

@ -0,0 +1,123 @@
-- auth/001: users + sessions — единое хранилище доступов для «Меры» и «Птицы».
--
-- WHY (почему отдельная БД и почему таблицы называются нейтрально):
-- Владелец продукта решил (2026-07-31) свести вход в «Меру» (trade-in, /trade-in) и
-- «Птицу» (раздел Site Finder, /site-finder/analysis/[cad]/ptica) к ОДНОЙ нейтральной
-- форме входа, вместо браузерного popup'а Caddy basic_auth. Значит, у хранилища доступов
-- два потребителя, и оно не должно принадлежать ни одному из них: живёт в отдельной БД
-- `auth` на платформенном сервере gendesign-postgres (тот же кластер, отдельная база —
-- новый контейнер не заводим; оба бэкенда сидят в сети gendesign_shared и TCP-достают
-- до gendesign-postgres-1:5432, проверено на проде 2026-07-31).
-- Отсюда имена без префикса продукта: `users`, а не `tradein_users`. Префикс продукта в
-- нейтральном хранилище означал бы, что вторая система — гость в чужой таблице, и через
-- полгода никто бы не помнил, кто владелец схемы.
--
-- Здесь НЕТ колонки `role` — сознательно. Идентичность («кто это, какой у него пароль,
-- активен ли доступ») общая для двух продуктов; полномочия внутри продукта (admin/manager/
-- employee в «Мере», админ-роуты в «Птице») — это знание продукта, оно остаётся в
-- продуктовых БД (tradein_users.role) и не переезжает сюда. Иначе `auth` пришлось бы
-- менять каждый раз, когда в одном из продуктов появляется новая роль.
--
-- WHAT:
-- 1. users — identity. password_hash NULL допустим (см. комментарий к колонке): пароли
-- НИКОГДА не попадают в git, ни plaintext, ни bcrypt-хешем — конвенция репо, прецедент
-- tradein-mvp/backend/data/sql/193_tradein_users_seed.sql. Сид (003) вставляет строки
-- с password_hash = NULL, хеши проставляются на проде отдельно.
-- 2. sessions — токен-based сессии, ON DELETE CASCADE от users (удалили пользователя —
-- его сессии теряют смысл). last_seen_at отдельно от created_at — для idle-timeout,
-- иначе «сессия жива 30 дней» и «человек не заходил 30 дней» неразличимы.
-- 3. ASCII-CHECK на username — обязателен ДО появления прод-данных (см. ниже).
--
-- IDEMPOTENCY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS; CHECK-констрейнты объявлены
-- inline в CREATE TABLE, а не через ALTER — при повторном прогоне CREATE TABLE не
-- выполняется вообще, значит констрейнт физически не может задублироваться (паттерн из
-- 192_tradein_users_auth.sql).
--
-- Тип id: `bigint GENERATED ALWAYS AS IDENTITY` — стандартный (SQL-standard) эквивалент
-- bigserial: та же bigint-колонка на той же последовательности, но sequence принадлежит
-- таблице жёстко и не переживает DROP COLUMN сиротой, а прямой INSERT в id запрещён
-- (случайная вставка «своего» id, ломающая счётчик, невозможна). Ровно так объявлен
-- tradein_users.id в 192 — держим один тип на обе таблицы, чтобы будущий код, читающий
-- обе, не спотыкался о разницу.
--
-- Dependencies: нет (пустая БД `auth`, создаётся bootstrap-шагом деплоя,
-- см. ops/db-bootstrap/create_auth_db.sql).
-- Deploy order: Foundation. Роль приложения + гранты — 002, сид — 003. Python-код логина,
-- логин-страница и снятие Caddy basic_auth — отдельные PR'ы ПОСЛЕ этого
-- (SQL-схема первой, см. .claude/rules/sql.md «Migration order»).
BEGIN;
CREATE TABLE IF NOT EXISTS users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NULL,
display_name text NULL,
org_name text NULL,
email text NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$')
);
COMMENT ON TABLE users IS
'Единое хранилище доступов для «Меры» (trade-in) и «Птицы» (Site Finder) — только '
'идентичность. Полномочия внутри продукта (роли) остаются в продуктовых БД: иначе эту '
'таблицу пришлось бы менять при каждом изменении ролевой модели любого из продуктов.';
COMMENT ON COLUMN users.password_hash IS
'NULL = пароль ещё не проставлен, вход по паролю для этой строки невозможен. Хеши '
'НИКОГДА не хранятся в git (ни в сидах, ни в фикстурах) — их проставляют на проде '
'отдельно от миграции; иначе один утёкший коммит открывает вход всем аккаунтам сразу.';
COMMENT ON COLUMN users.is_active IS
'false = доступ закрыт владельцем продукта. Отдельная колонка, а не удаление строки: '
'удаление каскадом снесло бы сессии и историю, а закрытие доступа обратимо и его надо '
'уметь отличать от «такого пользователя никогда не было».';
COMMENT ON COLUMN users.org_name IS
'Организация пользователя. NULL, пока реальные данные не подтверждены владельцем '
'продукта — выдуманное название хуже пустого, оно выглядит достоверным.';
COMMENT ON CONSTRAINT users_username_ascii_ck ON users IS
'Fail-closed запрет не-ASCII логинов (перенесено из tradein м.193, deep-review #2561): '
'downstream-код кодирует username сессии через encode("latin-1","replace"), поэтому два '
'кириллических логина ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку из «?» — '
'разные люди получают общую идентичность, общую квоту и взаимный IDOR (один видит данные '
'другого). Констрейнт на уровне схемы, а не проверка в UI/API: проверку в коде однажды '
'забудут добавить в новый путь создания пользователя, схему обойти нельзя.';
CREATE TABLE IF NOT EXISTS sessions (
token text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now(),
ip_address inet NULL,
user_agent text NULL
);
COMMENT ON TABLE sessions IS
'Активные сессии единой формы входа (общие для «Меры» и «Птицы»). ON DELETE CASCADE от '
'users: оставшаяся сессия удалённого пользователя — это действующий доступ без владельца.';
COMMENT ON COLUMN sessions.last_seen_at IS
'Обновляется на каждом запросе — нужен для idle-timeout: без него «сессия не истекла» и '
'«человек ещё работает» неразличимы, и забытая открытая вкладка живёт до expires_at.';
COMMENT ON COLUMN sessions.ip_address IS
'IP на момент выдачи токена — для разбора инцидентов («откуда зашли под этим логином»), '
'не для авторизации: привязка к IP ломает мобильных пользователей при смене сети.';
-- Индексы — как в tradein м.192: уборка протухших сессий по expires_at и выборка/отзыв
-- всех сессий одного пользователя по user_id (FK сам по себе индекс не создаёт, а без него
-- ON DELETE CASCADE на users делает seq scan по всей таблице сессий).
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx
ON sessions (expires_at);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx
ON sessions (user_id);
COMMIT;

View file

@ -0,0 +1,82 @@
-- auth/002: роль приложения auth_app + гранты (least privilege).
--
-- WHY:
-- Миграции этой БД прогоняются суперюзером кластера ($POSTGRES_USER), он же владелец
-- таблиц. Бэкенды «Меры» и «Птицы» ходить под суперюзером не должны: скомпрометированный
-- бэкенд не обязан уметь DROP TABLE users. Поэтому отдельная login-роль с точечными
-- грантами. БД `auth` НЕ принадлежит auth_app (владелец — суперюзер): владелец таблицы
-- имеет на неё все права независимо от GRANT'ов, и разграничение ниже стало бы фикцией.
--
-- Пароль роли здесь НЕ задаётся — роль создаётся passwordless, пароль ставится отдельным
-- bootstrap-шагом деплоя из env (AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime,
-- см. ops/db-bootstrap/set_auth_app_password.sql). Ровно тот же паттерн, что у
-- gendesign_reader (tradein м.101 + set_gendesign_reader_password.sql) и tradein_fdw_reader
-- (data/sql/100_tradein_fdw_role.sql). Пароль в git не попадает ни при каких условиях.
--
-- Периметр прав (обосновано по-операционно):
-- sessions — SELECT/INSERT/UPDATE/DELETE. Полный набор: выдать токен (INSERT), проверить
-- на каждом запросе (SELECT), обновить last_seen_at (UPDATE), разлогинить и вычистить
-- протухшие (DELETE).
-- users — SELECT (найти по username, прочитать hash и is_active) + UPDATE (смена пароля
-- самим пользователем и проставление хеша админом).
-- users — INSERT/DELETE НЕ выдаются, сознательно:
-- * INSERT — создание аккаунтов в PR-1 не существует ни как код, ни как UI. Выдать грант
-- «на будущее» = держать открытой операцию, которой никто не пользуется и которую никто
-- не тестирует. Когда появится админский путь создания пользователей, грант добавляется
-- новой миграцией в одну строку (плюс GRANT USAGE на sequence, идентичность требует
-- nextval). Обратная ошибка дороже: снять грант, на который уже опирается прод-код,
-- нельзя без синхронного релиза.
-- * DELETE — не выдаётся и дальше: закрытие доступа делается через is_active = false
-- (см. комментарий к колонке в 001). Физическое удаление каскадом сносит сессии и
-- обрывает связь с историей действий пользователя в продуктовых БД, где user_id/username
-- остаются висеть; это операция уровня «руками через psql с осознанием последствий»,
-- а не то, что должен уметь HTTP-хендлер.
--
-- IDEMPOTENCY:
-- CREATE ROLE через DO-блок с проверкой pg_roles (нет ADD ROLE IF NOT EXISTS), GRANT/REVOKE
-- идемпотентны по определению. Повторный прогон — no-op. Роли в PostgreSQL общие на кластер,
-- поэтому DO-блок отработает корректно, даже если роль уже создана из другой БД.
--
-- Dependencies: 001_identity_schema.sql (гранты ссылаются на users/sessions).
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN
CREATE ROLE auth_app LOGIN;
END IF;
END$$;
COMMENT ON ROLE auth_app IS
'Прикладная роль единой формы входа («Мера» + «Птица»). Пароль ставится '
'.forgejo/workflows/deploy.yml из env AUTH_DB_PASSWORD (backend/.env.runtime) через '
'ops/db-bootstrap/set_auth_app_password.sql. Пароль никогда не хранится в SQL-миграциях.';
-- Никто, кроме владельца БД и явно поименованных ролей, не должен даже подключаться:
-- по умолчанию PostgreSQL даёт CONNECT роли PUBLIC, то есть любая login-роль кластера
-- (glitchtip, tradein_fdw_reader, gendesign_reader) может открыть сессию в `auth`.
-- Хранилище паролей — не то место, где стоит полагаться на «а таблицы им всё равно не видны».
--
-- ЭТА СТРОКА ПРОДУБЛИРОВАНА в ops/db-bootstrap/create_auth_db.sql — намеренно, инвариант
-- держится в двух местах. Здесь — ради самодостаточности миграции: применённая на пустую БД
-- (scratch/staging, ручной psql -f) она обязана давать полный периметр прав, не полагаясь на
-- то, что кто-то отдельно прогнал bootstrap. В bootstrap — ради переприменяемости: миграция
-- выполняется РОВНО ОДИН РАЗ (трекинг в _schema_migrations), а БД может быть пересоздана из
-- дампа в обход миграций, и тогда дефолтный PUBLIC-CONNECT вернулся бы молча. Не «сокращай»
-- дубль — ни одна из копий не покрывает сценарий другой.
REVOKE ALL ON DATABASE auth FROM PUBLIC;
-- Defense-in-depth: явный REVOKE-периметр перед точечными грантами — любые унаследованные
-- или PUBLIC-гранты на существующих объектах обнуляются (паттерн из 100_tradein_fdw_role.sql).
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM auth_app;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM auth_app;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM auth_app;
GRANT CONNECT ON DATABASE auth TO auth_app;
GRANT USAGE ON SCHEMA public TO auth_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON sessions TO auth_app;
GRANT SELECT, UPDATE ON users TO auth_app;
COMMIT;

View file

@ -0,0 +1,111 @@
-- auth/003: сид 13 существующих аккаунтов (org-карта владельца продукта, 2026-07-30/31).
--
-- WHY:
-- 001 создала схему, но без данных единая форма входа не заработает: реальные аккаунты
-- сейчас живут только в Caddy basic_auth (caddy/users.caddy.snippet + tradein auth/roles.yaml)
-- и в tradein_users. Эта миграция переносит список людей — БЕЗ ЕДИНОГО ПАРОЛЯ.
--
-- password_hash = NULL у ВСЕХ строк. Это конвенция репо, а не недоделка: ни plaintext, ни
-- bcrypt-хеш не должны попадать в git (прецедент — tradein-mvp/backend/data/sql/
-- 193_tradein_users_seed.sql, там сид тоже вставляет NULL, хеши проставляются отдельно на
-- проде). Хеш в git — это офлайн-brute-force для любого, кто получил доступ к репозиторию,
-- и он переживает любую ротацию пароля в истории коммитов.
-- Пока hash = NULL, вход по паролю через новую форму для строки невозможен, но доступ НЕ
-- теряется: PR-1 ничего не переключает, прод продолжает пускать через существующий
-- Caddy basic_auth ровно как сейчас. Переключение — отдельные PR'ы.
--
-- Состав (утверждён владельцем продукта):
-- admin — владелец
-- kopylov — отдельный клиент, display_name «Копылов»
-- praktika — ГК «Практика»
-- user1, user3..user10 — свободные слоты, is_active = true
-- user2 — «Брусника», is_active = FALSE (доступ закрыт 2026-07-30);
-- в roles.yaml он role=expired — расхождение семантики,
-- см. ⚠️ у строки user2 в VALUES ниже
-- display_name заполнен только у kopylov (единственная фамилия, подтверждённая в коде:
-- tradein auth.py::_USERNAME_PROFILE). Остальным NULL — реальных данных нет, выдумывать
-- нельзя: выдуманное ФИО в UI неотличимо от настоящего.
-- QA-фикстуры НЕ мигрируются — им нечего делать в общем хранилище доступов двух продуктов.
-- Состав фикстур неоднороден, и это важно при сверке списков (проверено по обоим файлам):
-- admintest, pilottest — действующие логины: есть И в caddy/users.caddy.snippet
-- (basic_auth-запись с хешем), И в auth/roles.yaml (role-mapping). Реально входят.
-- analysttest, expiredtest — существуют ТОЛЬКО в auth/roles.yaml как role-mapping,
-- basic_auth-записи в caddy/users.caddy.snippet у них нет, то есть войти под ними
-- снаружи сегодня нельзя вообще. Это тестовые фикстуры, а не аккаунты: analysttest
-- гоняется в backend/tests (test_rbac.py, test_insights.py, test_audit_middleware.py),
-- expiredtest — в tradein-mvp/backend/tests/test_rbac.py как покрытие role=expired.
--
-- IDEMPOTENCY (логика и обоснование перенесены из tradein м.193, deep-review #2564):
-- INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, display_name,
-- org_name, email защищены COALESCE(текущее, EXCLUDED). Если админ уже проставил пароль или
-- поправил профиль между двумя прогонами файла (обычный auto-apply трекает filename в
-- _schema_migrations и не запускает файл дважды на одном окружении — но ручной re-apply при
-- recovery и scratch/staging БД такого трекинга не имеют), повторный прогон НЕ должен
-- затереть это состояние NULL-ом. В м.193 это был живой баг: назначенный через API manager_id
-- тихо обнулялся повторным прогоном сида.
-- Направление COALESCE односторонее: NULL в БД можно дозаполнить значением из сида, но
-- значение из БД никогда не перетирается сидом.
--
-- is_active НАМЕРЕННО отсутствует в SET — и не как COALESCE тоже: колонка NOT NULL, значит
-- COALESCE(NOT NULL-значение, x) никогда не возьмёт x, это был бы мёртвый код с видимостью
-- защиты. Открытие/закрытие доступа — решение владельца продукта, оно принимается в
-- интерфейсе, а не повторным прогоном seed-файла: после первой вставки колонка сознательно
-- «замораживается» на текущем значении в БД.
-- (В м.193 в SET присутствовал ещё role — как источник истины org-карты. Здесь колонки role
-- нет вовсе: полномочия остаются в продуктовых БД, см. заголовок 001.)
--
-- updated_at = now() выставляется на любом конфликте, даже когда ни одна колонка фактически
-- не изменилась — паритет с м.193; «строка была затронута прогоном сида» это честно отражает.
--
-- Разрывы в users.id после повторного прогона — норма, НЕ следы удалённых строк. Дефолт
-- GENERATED ALWAYS AS IDENTITY вычисляется ДО обнаружения конфликта, поэтому каждый
-- повторный прогон сжигает 13 значений последовательности впустую. Функционально безвредно;
-- упомянуто, чтобы дыры в id не увели разбор инцидента в сторону «кого-то удалили».
--
-- Dependencies: 001_identity_schema.sql (users + ASCII-CHECK на username; все логины ниже
-- ASCII, констрейнту не противоречат).
BEGIN;
INSERT INTO users (username, password_hash, display_name, org_name, email, is_active)
VALUES
('admin', NULL, NULL, NULL, NULL, true),
('kopylov', NULL, 'Копылов', NULL, NULL, true),
('praktika', NULL, NULL, NULL, NULL, true),
('user1', NULL, NULL, NULL, NULL, true),
-- user2 — «Брусника», доступ закрыт владельцем продукта 2026-07-30.
--
-- ⚠️ ОТКРЫТАЯ РАЗВИЛКА, решается в PR-2/3 (переключение на единую форму входа), НЕ здесь:
-- сегодня в auth/roles.yaml у user2 role=expired, и семантика ДРУГАЯ, чем is_active=false.
-- expired != disabled: expired-юзер проходит гейт (basic_auth-запись в
-- caddy/users.caddy.snippet у него есть), доходит до фронта и видит осмысленный экран
-- «пробный доступ закончился» (roles.yaml → блок expired: paths: [] + deny "/**";
-- frontend NoAccessScreen variant="trial"). is_active=false — это отказ на этапе входа,
-- неотличимый для пользователя от «неверный пароль».
-- Сейчас расхождение безобидно: PR-1 ничего не переключает, прод по-прежнему ходит через
-- Caddy basic_auth + roles.yaml, и никакой код эту колонку не читает. Но в момент
-- переключения trial-экран пропадёт МОЛЧА — тесты не упадут, роль просто перестанет
-- существовать как состояние. Решать тогда: если trial-UX сохраняем, нужно отдельное
-- состояние (колонка status / отдельная роль), а не булев флаг — is_active схлопывает
-- «доступ закрыт» и «пробный период истёк» в одно значение. Схему в этом PR НЕ трогаем.
('user2', NULL, NULL, NULL, NULL, false),
('user3', NULL, NULL, NULL, NULL, true),
('user4', NULL, NULL, NULL, NULL, true),
('user5', NULL, NULL, NULL, NULL, true),
('user6', NULL, NULL, NULL, NULL, true),
('user7', NULL, NULL, NULL, NULL, true),
('user8', NULL, NULL, NULL, NULL, true),
('user9', NULL, NULL, NULL, NULL, true),
('user10', NULL, NULL, NULL, NULL, true)
ON CONFLICT (username) DO UPDATE SET
-- COALESCE(текущее, EXCLUDED): сид дозаполняет пустые поля, но никогда не затирает
-- уже проставленные вручную (в первую очередь password_hash — иначе повторный прогон
-- отключал бы вход всем, кому пароль уже выдали).
password_hash = COALESCE(users.password_hash, EXCLUDED.password_hash),
display_name = COALESCE(users.display_name, EXCLUDED.display_name),
org_name = COALESCE(users.org_name, EXCLUDED.org_name),
email = COALESCE(users.email, EXCLUDED.email),
-- is_active НЕ в SET: NOT NULL-колонка, COALESCE был бы мёртвым кодом (см. IDEMPOTENCY).
updated_at = now();
COMMIT;

View file

@ -0,0 +1,408 @@
-- auth/004: продуктовые роли + org-иерархия + трёхзначный access_state вместо булева is_active.
--
-- ⚠️ ЭТА МИГРАЦИЯ СОЗНАТЕЛЬНО ОТМЕНЯЕТ РЕШЕНИЯ, ЗАПИСАННЫЕ В 001 И 002.
-- Это не рассинхрон и не ошибка автора: решение владельца продукта от 2026-07-31 принято
-- ПОСЛЕ того, как 001-003 были написаны и применены на проде. Применённую миграцию править
-- нельзя (повторно она не выполнится — трекинг в _schema_migrations), поэтому актуальная
-- правда живёт здесь, а в 001/002 остаются исторические формулировки:
-- * 001:15-19 «Здесь НЕТ колонки role — сознательно» → ОТМЕНЕНО, см. WHY-1;
-- * 002:22-33 «users — INSERT/DELETE НЕ выдаются, сознательно» → ОТМЕНЕНО ЧАСТИЧНО: INSERT
-- выдаётся (без него переезд не состоится), DELETE — по-прежнему нет, см. Часть 4;
-- * 002:26-27 «идентичность требует nextval» (грант USAGE на sequence) → ФАКТИЧЕСКИ
-- НЕВЕРНО, гранта не требуется; проверено, разбор в Части 4;
-- * 003:78-90 «открытая развилка про trial-экран, решается в PR-2/3» → ЗАКРЫТА, см. WHY-2.
-- Ориентир для читателя: актуальное состояние колонок описано COMMENT'ами в БД, они
-- переписаны здесь. Заголовок 001 — археология, а не спецификация.
--
-- WHY-1 — продуктовые роли переезжают в `auth` (отмена решения 001):
-- 001 строилась на схеме «идентичность общая, полномочия у продукта»: auth.users знает, КТО
-- человек, tradein_users знает, ЧТО ему можно. Владелец выбрал другой сценарий — ПОЛНЫЙ
-- переезд: tradein_users (БД tradein) в итоге удаляется, auth.users остаётся единственным
-- реестром людей. Как только реестр один, роль перестаёт быть «знанием продукта»: без неё в
-- auth.users нельзя ни завести сотрудника, ни собрать раздел «Команда», ни ответить на вопрос
-- «чьи заявки видит этот менеджер» — а спросить больше не у кого, второй таблицы не будет.
-- Промежуточный вариант (человек в auth.users, его роль в tradein_users) — это два реестра,
-- которые кто-то обязан держать синхронными руками; их расхождение выглядит как «пользователь
-- есть, но он никто» и чинится только вручную по факту жалобы.
-- Цена решения ровно та, которую 001 и называла: новая роль в любом из продуктов = миграция
-- этой БД. Принято сознательно — это дешевле, чем двойной реестр людей.
--
-- WHY-2 — три состояния доступа вместо булева is_active (закрытие развилки из 003):
-- Булев флаг схлопывает два РАЗНЫХ события в одно значение: «пробный период закончился» и
-- «доступ закрыт владельцем». Для пользователя разница видимая и она уже реализована в
-- сегодняшнем стеке: expired-аккаунт доходит до фронта и видит осмысленный экран «пробный
-- доступ закончился» (auth/roles.yaml → expired: paths: [] + deny "/**"; frontend
-- NoAccessScreen variant="trial"), а закрытый — просто не входит. Переключившись на единую
-- форму входа с булевым is_active, мы бы потеряли trial-экран МОЛЧА: состояние перестало бы
-- существовать, и ни один тест бы не упал. Ровно это и было записано как открытая развилка в
-- 003:78-90. Решение: состояний три.
-- active — доступ есть, обычный вход.
-- trial_expired — пароль ВЕРНЫЙ, но пробный период истёк: логин отвечает 403 с отдельным
-- кодом и текстом «пробный доступ закончился», сессия НЕ выдаётся.
-- disabled — жёсткая блокировка: generic 401, для пользователя неотличимо от «неверный
-- пароль».
-- Неверный пароль в ЛЮБОМ состоянии → generic 401. Иначе отдельный 403 превращается в оракул
-- существования логина: перебором можно перечислить аккаунты, не зная ни одного пароля.
-- Осмысленный ответ полагается только тому, кто пароль уже доказал.
-- text + CHECK, а не enum-тип: добавить четвёртое состояние — это ALTER одного констрейнта в
-- обычной миграции, тогда как ALTER TYPE ... ADD VALUE нельзя использовать в той же
-- транзакции, где значение добавлено (PG16), и enum тянет за собой отдельный тип в дампах.
-- Enum-типов в репозитории нет вовсе — не заводим первый ради трёх значений.
--
-- WHAT:
-- 1. role — text NOT NULL + CHECK ('admin','manager','employee'). Тип, набор значений
-- и отсутствие DEFAULT — зеркало tradein_users.role (м.192:42).
-- 2. manager_id — self-FK ON DELETE SET NULL + иерархический CHECK + запрет self-manager +
-- partial index. Зеркало м.192:43/50-52/84-86, чтобы код «Меры» переехал на
-- auth.users без правок.
-- 3. access_state — text NOT NULL DEFAULT 'active' + CHECK на три значения; backfill из
-- is_active, точечный перевод user2 («Брусника») в trial_expired, затем
-- DROP COLUMN is_active.
-- 4. Гранты auth_app — INSERT на users (DELETE НЕ выдаётся) + сужение табличного UPDATE (002:80) до
-- column-level: новые колонки role/access_state не должны попасть под него
-- молча.
--
-- IDEMPOTENCY:
-- ADD COLUMN IF NOT EXISTS / DROP COLUMN IF EXISTS / CREATE INDEX IF NOT EXISTS; констрейнты —
-- через DO-блок с проверкой pg_constraint (в PostgreSQL нет ADD CONSTRAINT IF NOT EXISTS для
-- CHECK/FK, паттерн из м.193:80-90); GRANT идемпотентен по определению; UPDATE-backfill'ы
-- отфильтрованы так, что второй прогон не находит строк (детали у каждого блока).
-- Проверка pg_constraint здесь фильтрует ДОПОЛНИТЕЛЬНО по conrelid (в отличие от м.193, где
-- только conname): имена констрейнтов уникальны в пределах таблицы, а не БД — одноимённый
-- констрейнт на соседней таблице заставил бы миграцию молча пропустить создание своего.
--
-- ⚠️ ПОСЛЕ 004 ФАЙЛЫ 001 И 003 БОЛЬШЕ НЕ ПЕРЕИГРЫВАЮТСЯ ПООТДЕЛЬНОСТИ.
-- Обе ссылаются на колонку is_active, которой после этой миграции нет, и обе падают на уже
-- мигрированной БД с «column is_active does not exist»:
-- * 001 — на `COMMENT ON COLUMN users.is_active` (001:75). CREATE TABLE IF NOT EXISTS
-- пропускается, а COMMENT выполняется всегда — то есть ручной `psql -f 001` падает
-- РАНЬШЕ 003, вопреки интуиции «ломается только сид».
-- * 003 — на INSERT со списком колонок, включающим is_active (а если бы и не упал —
-- role NOT NULL без DEFAULT не даст вставить строку).
-- Это следствие требования «применённые миграции не правим», а не регресс. Поддерживаемый
-- сценарий восстановления — прогон каталога ЦЕЛИКОМ по возрастанию номеров (001→002→003→004)
-- на пустой БД; он рабочий, порядок гарантирован сортировкой имён в deploy.yml. Нужно добить
-- сид на живой БД — пиши новый файл 00N, не переигрывай 003.
--
-- Dependencies: 001_identity_schema.sql (users), 002_auth_app_role.sql (роль auth_app — гранты
-- Части 4 её предполагают), 003_users_seed.sql (13 строк, которым backfill проставляет role).
-- Deploy order: применяется на прод авто-циклом deploy.yml по data/sql/auth/*.sql. Python-кода в
-- этом PR нет и поведение прода не меняется — в БД `auth` пока никто не ходит; код логина,
-- чтение role/access_state и удаление tradein_users — отдельные PR'ы ПОСЛЕ (см.
-- .claude/rules/sql.md «Migration order»: схема первой).
BEGIN;
-- ---------------------------------------------------------------------------------------------
-- Часть 1: role
-- ---------------------------------------------------------------------------------------------
-- DEFAULT сознательно НЕТ (как в м.192): роль — осознанное решение того, кто заводит человека.
-- С дефолтом INSERT, забывший указать роль, тихо создал бы работающий аккаунт с полномочиями
-- «по умолчанию»; без дефолта он падает на NOT NULL — это и есть нужное поведение.
-- Колонка добавляется NULLable, заполняется backfill'ом ниже и только потом получает NOT NULL:
-- прямой ADD COLUMN ... NOT NULL без DEFAULT упал бы на 13 уже существующих строках сида.
ALTER TABLE users ADD COLUMN IF NOT EXISTS role text;
-- Backfill. Источник истины — м.193:101-113 (org-карта владельца продукта от 2026-07-30),
-- сверено построчно по файлу, не по памяти. Роли не являются секретом: они уже лежат в git
-- (м.193 и auth/roles.yaml) — запрет на git касается паролей и хешей, не полномочий.
-- `role IS NULL` в каждом WHERE даёт сразу две вещи: идемпотентность (второй прогон не находит
-- строк) и защиту от отката ручных решений — повышение сотрудника до manager, сделанное после
-- первого прогона, повторным применением файла не вернётся к seed-значению.
UPDATE users SET role = 'admin' WHERE role IS NULL AND username = 'admin';
UPDATE users SET role = 'manager' WHERE role IS NULL AND username IN ('kopylov', 'praktika');
-- Catch-all — ПОСЛЕДНИМ и именно employee: любая строка, попавшая в auth.users мимо сида
-- (ручная вставка, восстановление из дампа, будущий аккаунт), получает НАИМЕНЕЕ
-- привилегированную роль. Fail-safe: ошибка в этом месте не должна раздавать admin.
UPDATE users SET role = 'employee' WHERE role IS NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_role_ck CHECK (role IN ('admin', 'manager', 'employee'));
END IF;
END $$;
-- SET NOT NULL идемпотентен (на уже NOT NULL колонке — no-op) и стоит ПОСЛЕ backfill: на строке
-- с NULL он упал бы, а catch-all выше гарантирует, что таких строк не осталось.
ALTER TABLE users ALTER COLUMN role SET NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- Часть 2: manager_id (org-иерархия)
-- ---------------------------------------------------------------------------------------------
-- FK и CHECK объявлены ОТДЕЛЬНЫМИ шагами, а не inline в ADD COLUMN (как в м.192, где это было
-- частью CREATE TABLE IF NOT EXISTS — «всё или ничего»). Причина: `ADD COLUMN IF NOT EXISTS ...
-- REFERENCES ...` пропускает ВЕСЬ оператор, если колонка уже есть, — на БД, где manager_id
-- когда-то завели руками без FK, миграция отчиталась бы об успехе и оставила связь без
-- ссылочной целостности. Раздельные идемпотентные шаги такого состояния не допускают.
-- Имя FK задано явно тем же, которое сгенерировал бы PostgreSQL для inline-формы, — чтобы схема
-- на проде и схема из чистой сборки не различались именами констрейнтов.
ALTER TABLE users ADD COLUMN IF NOT EXISTS manager_id bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_manager_id_fkey' AND conrelid = 'users'::regclass
) THEN
-- ON DELETE SET NULL (зеркало м.192:43): удаление менеджера не должно каскадом сносить
-- его сотрудников — они остаются в реестре без привязки, и это чинится назначением
-- нового менеджера, а не восстановлением строк из бэкапа.
ALTER TABLE users
ADD CONSTRAINT users_manager_id_fkey
FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_manager_hierarchy_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_role_manager_hierarchy_ck CHECK (
role NOT IN ('admin', 'manager') OR manager_id IS NULL
);
END IF;
END $$;
-- Запрет self-manager. users_role_manager_hierarchy_ck выше держит только admin/manager; для
-- employee self-FK допускает ссылку строки на саму себя, и `UPDATE users SET manager_id = id`
-- прошёл бы. Через сегодняшний API это недостижимо (team.py:398-406 требует role='manager' у
-- цели, PATCH manager_id вообще не меняет), но 004 делает auth.users ЕДИНСТВЕННЫМ реестром — в
-- него начнёт писать и «Птица», у которой этой валидации нет, а любой будущий WITH RECURSIVE по
-- manager_id на такой строке зациклится. Строчный CHECK ловит самый вероятный случай (опечатка
-- или копипаста собственного id) и стоит ноль.
-- Чего этот констрейнт НЕ ловит: взаимную пару employee↔employee (A.manager_id=B,
-- B.manager_id=A) и ссылку на строку с role<>'manager' — оба требуют чтения ДРУГОЙ строки,
-- строчным CHECK'ом это не выражается (нужен триггер или FK на несуществующий уникальный ключ
-- (id, role)). Инвариант зафиксирован COMMENT'ом к колонке — он живёт в приложении.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_manager_not_self_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_manager_not_self_ck CHECK (
manager_id IS NULL OR manager_id <> id
);
END IF;
END $$;
-- Partial index (зеркало м.192:84-86): у admin/manager и у свободных слотов manager_id = NULL,
-- и эти строки никогда не участвуют в выборке «сотрудники этого менеджера». Индексировать NULL'ы
-- значит платить за большую часть таблицы, которая по этому пути не читается.
CREATE INDEX IF NOT EXISTS users_manager_id_idx
ON users (manager_id)
WHERE manager_id IS NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- Часть 3: access_state вместо is_active
-- ---------------------------------------------------------------------------------------------
-- DEFAULT 'active' здесь, в отличие от role, уместен: «доступ есть» — это состояние, в котором
-- заводят любого нового сотрудника, и молчаливый дефолт не расширяет ничьих полномочий.
ALTER TABLE users ADD COLUMN IF NOT EXISTS access_state text NOT NULL DEFAULT 'active';
-- CHECK ставится СРАЗУ после колонки, до backfill'а: тогда он проверяет и сам backfill —
-- опечатка в значении ниже уронит миграцию, а не просочится в данные.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_access_state_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_access_state_ck CHECK (
access_state IN ('active', 'trial_expired', 'disabled')
);
END IF;
END $$;
-- Backfill из is_active — под проверкой существования колонки, потому что в конце этого же
-- блока она удаляется: повторный прогон файла обязан пройти без ошибок, а прямое обращение к
-- несуществующей колонке — ошибка парсинга, не «0 строк».
-- EXECUTE (динамический SQL), а не обычные UPDATE внутри IF: обычные операторы уцелели бы лишь
-- благодаря ленивой подготовке операторов в PL/pgSQL (невыполненная ветка не разбирается). Это
-- рабочая, но недокументированная в самом файле деталь реализации; EXECUTE делает независимость
-- от отсутствующей колонки явной для читателя.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = 'users'::regclass
AND attname = 'is_active'
AND NOT attisdropped
) THEN
-- Механическое отображение старой семантики: булев «доступ закрыт» = жёсткая блокировка.
-- `access_state = 'active'` в WHERE — не мёртвое условие: оно фиксирует, что переписывается
-- только значение, доставшееся из DEFAULT, и никогда — уже осмысленно проставленное.
EXECUTE $q$
UPDATE users
SET access_state = 'disabled'
WHERE is_active = false
AND access_state = 'active'
$q$;
-- Точечно: user2 («Брусника», доступ закрыт владельцем 2026-07-30) — не disabled, а
-- trial_expired. Основание: в auth/roles.yaml у него role=expired, то есть исторически он
-- видит trial-экран, а не отказ входа; решение владельца от 2026-07-31 эту семантику
-- сохраняет.
-- Условие `access_state = 'disabled'` — это защита от затирания ручного решения:
-- переводится РОВНО то значение, которое механическая ветка выше только что и вывела.
-- Если к моменту повторного прогона владелец уже открыл «Бруснике» доступ (active) или
-- перевёл её в другое состояние, WHERE не сматчится и решение человека переживёт миграцию.
-- Безусловный UPDATE по username возвращал бы аккаунт в trial_expired после каждого
-- прогона, и разбор «почему у клиента снова экран пробного периода» стоил бы часов при
-- нулевой пользе. Хардкод одного username оправдан: это разовая фиксация конкретного
-- исторического факта, а не правило — общего признака «пробный доступ» в схеме до сих пор
-- не было, выводить его задним числом не из чего.
EXECUTE $q$
UPDATE users
SET access_state = 'trial_expired'
WHERE username = 'user2'
AND access_state = 'disabled'
$q$;
END IF;
END $$;
-- Снятие is_active. Деструктивный шаг — но именно он и есть смысл решения: оставить обе колонки
-- значило бы два источника правды о доступе, расходящихся при первой же правке через UI.
-- Безопасно: на момент этого PR БД `auth` не читается ни одним работающим кодом (Caddy basic_auth
-- + tradein_users по-прежнему обслуживают прод), а данные колонки полностью перенесены выше.
-- DROP обязан жить именно здесь, а не в 003: 003 применён на проде и правке не подлежит.
ALTER TABLE users DROP COLUMN IF EXISTS is_active;
-- ---------------------------------------------------------------------------------------------
-- Часть 4: гранты auth_app под режим единственного реестра (отмена решения 002:22-33)
-- + сужение унаследованного табличного UPDATE до column-level
-- ---------------------------------------------------------------------------------------------
-- 002 намеренно не выдавала INSERT/DELETE на users, и её аргумент был верным для своего момента:
-- в PR-1 не существовало ни кода, ни UI создания аккаунтов, а грант «на будущее» — это открытая
-- операция, которой никто не пользуется и которую никто не тестирует. Аргумент перестаёт
-- применяться ровно сейчас: после полного переезда auth.users — единственный реестр людей, а
-- раздел «Команда» «Меры» (tradein-mvp/backend/app/api/v1/team.py: POST /employees заводит
-- сотрудника, PATCH правит) — единственный интерфейс, которым сотрудника заводят и убирают.
-- Без INSERT переезд физически не состоится: сегодняшний INSERT идёт в tradein_users, а её не
-- станет.
-- DELETE здесь НЕ выдаётся, хотя первая редакция этой миграции его содержала. Причина отказа:
-- DELETE-эндпоинта в team.py нет (только POST /employees и PATCH — проверено), то есть потребителя
-- у права нет ни одного, а 002:22-33 отклоняла ровно такие гранты-на-будущее. Симметричный
-- контраргумент («снять неиспользуемое право дешевле, чем добавлять его в момент релиза») здесь не
-- перевешивает: DELETE по users каскадит на sessions (001:94), то есть цена ошибки в коде выше
-- обычной, а добавить строку GRANT в миграцию того PR, где появится DELETE-хендлер, стоит ровно
-- столько же. Право выдаётся вместе с кодом, который им пользуется, — не раньше.
-- DELETE ≠ закрытие доступа. Закрытие — это access_state ('disabled' / 'trial_expired'):
-- обратимо, сохраняет строку и историю. Именно оно, а не удаление строки, закрывает сегодняшний
-- сценарий «Команды»; удаление понадобилось бы только чтобы убрать ошибочно заведённый слот.
GRANT INSERT ON users TO auth_app;
-- Гранта на последовательность users_id_seq здесь НЕТ — и это не забывчивость.
-- 002:26-27 записала как факт, что «идентичность требует nextval», то есть INSERT из auth_app
-- якобы упадёт с «permission denied for sequence» без USAGE на последовательности. Для
-- `GENERATED ALWAYS AS IDENTITY` (001:53) это неверно: PostgreSQL подставляет не вызов
-- nextval('...'), а узел NextValueExpr, который дёргает nextval_internal(seqid,
-- check_permissions := false) — ACL последовательности не проверяется вовсе. Это документированное
-- отличие identity от serial, и оно проверено живьём на postgres:16, а не выведено из
-- документации: после `REVOKE ALL ON SEQUENCE users_id_seq FROM app` INSERT в identity-таблицу
-- прошёл и вернул id, тогда как в контрольной таблице с bigserial тот же INSERT в тех же
-- условиях упал ровно с «permission denied for sequence».
-- Отсюда два следствия. Первое: грант не нужен — он выдал бы auth_app право звать
-- nextval('users_id_seq') напрямую (жечь идентификаторы) и читать last_value (число заведённых
-- аккаунтов), при том что ни один путь кода этого не делает; это прямо противоречило бы
-- REVOKE ALL ON ALL SEQUENCES из 002:73. Второе: «живая проверка» вида «auth_app сделал INSERT,
-- значит грант рабочий» ничего не доказывает — тот же INSERT проходит и после REVOKE, поэтому
-- проверять надо обратное (REVOKE, затем INSERT).
-- Если users.id когда-нибудь переведут на обычный DEFAULT nextval(...) — грант станет
-- обязательным, и его придётся добавить той же миграцией, что меняет колонку.
-- Сужение UPDATE до column-level. 002:80 выдала ТАБЛИЧНЫЙ `GRANT SELECT, UPDATE ON users`,
-- обосновав его узко («смена пароля самим пользователем и проставление хеша админом»), — но
-- табличный UPDATE автоматически распространяется на любые колонки, добавленные позже. Не сузь
-- мы его здесь, auth_app молча получил бы право писать role и access_state, и периметр 002
-- расширился бы ровно тем, что 004 добавила, без единой строки GRANT.
-- Почему это важно именно для этих двух колонок: любая SQL-инъекция или логическая ошибка в
-- UPDATE-эндпоинте (сегодня такой ровно один — team.py PATCH /employees, COALESCE-список полей
-- по WHERE id = :id) из «испортил профиль» превращалась бы в `SET role='admin' WHERE id=<свой>`
-- или `SET access_state='active' WHERE username='user2'` — тихое повышение до админа и тихое
-- снятие блокировки, без смены пароля, то есть без внешнего признака компрометации. Это ровно
-- тот класс, ради которого 002 и заводила отдельную роль (002:5-6).
-- role в список НЕ включена сознательно: сегодня её не пишет никто (team.py POST вставляет
-- литерал 'employee', PATCH в SET-списке role/manager_id не имеет вовсе). Появится админский
-- путь смены роли — добавится одной строкой новой миграции; это дешевле, чем держать открытым
-- право на эскалацию привилегий «на всякий случай».
-- manager_id по той же причине не включён: назначение сотрудника менеджеру сегодня делается
-- только при создании (INSERT), а не UPDATE'ом.
-- access_state включён — блокировка/разблокировка через «Команду» (сегодняшний
-- `is_active = COALESCE(...)` в PATCH) переезжает именно в эту колонку.
-- REVOKE перед GRANT обязателен и идемпотентен: REVOKE табличной привилегии снимает и
-- колоночные, поэтому повторный прогон файла даёт то же состояние (внутри одной транзакции,
-- то есть без окна «прав нет» для работающего приложения).
REVOKE UPDATE ON users FROM auth_app;
GRANT UPDATE (password_hash, display_name, org_name, email, access_state, updated_at)
ON users TO auth_app;
-- ---------------------------------------------------------------------------------------------
-- COMMENT'ы: переписываем то, что 004 сделала неверным в 001
-- ---------------------------------------------------------------------------------------------
COMMENT ON TABLE users IS
'Единый реестр людей для «Меры» (trade-in) и «Птицы» (Site Finder): идентичность И '
'полномочия. Решение владельца продукта 2026-07-31 — ПОЛНЫЙ переезд: tradein_users '
'удаляется, второго реестра не будет. Прежняя формулировка («роли остаются в продуктовых '
'БД», 001) отменена миграцией 004 — см. её заголовок.';
COMMENT ON COLUMN users.role IS
'Полномочия: admin | manager | employee. Зеркало tradein_users.role (tradein м.192) — код '
'«Меры» должен переехать на эту таблицу без правок в проверках роли. DEFAULT намеренно нет: '
'роль выбирает тот, кто заводит человека; INSERT без роли обязан падать, а не создавать '
'аккаунт с полномочиями «по умолчанию».';
COMMENT ON COLUMN users.manager_id IS
'Self-FK на users(id), ON DELETE SET NULL: удаление менеджера оставляет его сотрудников в '
'реестре без привязки, а не сносит их каскадом. NULL для admin/manager (top-level роли, '
'констрейнт users_role_manager_hierarchy_ck) и для employee без организации. '
'ИНВАРИАНТЫ, КОТОРЫЕ БД НЕ ПРОВЕРЯЕТ (обязан держать КАЖДЫЙ пишущий сюда код — реестр общий '
'для «Меры» и «Птицы»): цель ссылки обязана иметь role = ''manager''; циклы (A→B, B→A) '
'запрещены — рекурсивный обход иерархии на них зациклится. Схемой ловится только ссылка '
'строки на саму себя (users_manager_not_self_ck): остальное требует чтения другой строки и '
'строчным CHECK не выражается. Отсутствие проверки в БД — не разрешение.';
COMMENT ON COLUMN users.access_state IS
'Состояние доступа, три значения — заменило булев is_active (миграция 004). '
'active: вход разрешён. '
'trial_expired: пробный период истёк — при ВЕРНОМ пароле логин отвечает 403 с отдельным '
'кодом и текстом «пробный доступ закончился», сессия не выдаётся (аккаунт видит осмысленный '
'экран, а не «неверный пароль»). '
'disabled: доступ закрыт — generic 401, неотличимо от неверного пароля. '
'Неверный пароль в любом состоянии → generic 401: иначе отдельный ответ для trial_expired '
'стал бы оракулом существования логина. Булев флаг схлопывал бы trial_expired и disabled в '
'одно значение, и trial-экран исчез бы молча. '
'ИНВАРИАНТ ДЛЯ API (в БД не выразим): перевод ПОСЛЕДНЕГО active-админа в любое другое '
'состояние обязан отклоняться на уровне приложения. Констрейнт с role не связан, '
'UPDATE ... SET access_state = ''disabled'' WHERE username = ''admin'' в БД проходит, а после '
'перехода на единую форму входа это self-lockout: не остаётся аккаунта, способного открыть '
'доступ обратно через UI, восстановление — только psql на прод-БД. Сегодня путь закрыт тем, '
'что «Команда» не отдаёт строки с role = ''admin'' никому (team.py); любой новый админский '
'экран, пишущий access_state, обязан проверку восстановить.';
COMMENT ON CONSTRAINT users_role_manager_hierarchy_ck ON users IS
'admin/manager обязаны иметь manager_id IS NULL — это top-level роли, «начальника» у них в '
'этой модели нет (зеркало tradein м.192). Для employee manager_id любой, включая NULL '
'(свободный слот без организации допустим).';
COMMENT ON CONSTRAINT users_manager_not_self_ck ON users IS
'Строка не может быть собственным менеджером (manager_id <> id). Ловит опечатку/копипасту '
'id при ручной правке и у второго потребителя реестра («Птица»), где валидации «Команды» '
'нет. Взаимные пары и ссылку на не-менеджера строчный CHECK не ловит — см. COMMENT к '
'users.manager_id.';
COMMENT ON CONSTRAINT users_access_state_ck ON users IS
'Фиксирует ровно три состояния доступа. Расширение — новой миграцией с ALTER этого '
'констрейнта; тип text + CHECK выбран вместо enum именно ради дешёвого расширения.';
COMMIT;

View file

@ -17,11 +17,77 @@
#
# 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/<id>/ (умирает вместе с контейнером).
# Тот же 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=<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
# #2812: /dev/shm под dynamic_shared_memory_type=posix. Умолчание Docker — 64 МБ,
# и параллельные планы кладут туда свои DSM-сегменты. Прод-замер 2026-08-10:
# база постоянно держит ~9.8 МиБ (DSA кумулятивной статистики pgstat), один
# параллельный запрос Объектива берёт ~15.4 МиБ → 4-й одновременный не влезает
# в 64 МиБ и падает `DiskFull: could not resize shared memory segment`. Ровно это
# и случилось: 6 отказов за 1.2 с (market_metrics / sales_series / special_indices).
# 1 ГиБ = ~65 таких запросов; потолок celery (--concurrency=8) + request-path ≈ 12.
# tmpfs выделяется ПО ФАКТУ: значение — потолок, не резерв (0 Б до первого запроса).
# Rollback = убрать строку (снова 64 МиБ) + пересоздать контейнер.
shm_size: 1gb
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
@ -46,6 +112,7 @@ services:
redis:
image: redis:7-alpine
logging: *default-logging
restart: unless-stopped
volumes:
- redis_data:/data
@ -54,6 +121,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 +177,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 +205,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 +221,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 +257,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 +277,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 +307,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 +326,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.
@ -233,9 +341,19 @@ services:
REDIS_URL: redis://redis:6379/2
SECRET_KEY: ${GLITCHTIP_SECRET}
PORT: "8080"
EMAIL_URL: consolemail://
# Почта отключена по умолчанию: consolemail:// печатает письмо в stdout и
# никуда его не отправляет. Реальный адрес приходит из /opt/gendesign/.env
# (GLITCHTIP_EMAIL_URL) — в репозитории пароля почтового ящика быть не должно.
#
# ВАЖНО про схему DSN (django-environ, парсер GlitchTip): для порта 465 с
# implicit SSL нужна схема smtp+ssl://, а НЕ smtps:// — вторая помечена
# deprecated и включает STARTTLS (EMAIL_USE_TLS), то есть 465 с ней рвёт
# соединение. Для 587/STARTTLS схема — smtp+tls://.
# smtp+ssl://alerts%40meraocenka.ru:ПАРОЛЬ@smtp.beget.com:465
# Логин — почтовый адрес целиком, @ в нём кодируется как %40.
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
GLITCHTIP_DOMAIN: https://errors.gendsgn.ru
DEFAULT_FROM_EMAIL: errors@gendsgn.ru
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru}
ENABLE_USER_REGISTRATION: "true"
ENABLE_ORGANIZATION_CREATION: "false"
restart: always
@ -254,6 +372,7 @@ services:
glitchtip-worker:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-worker
logging: *default-logging
profiles: ["glitchtip"]
depends_on:
postgres:
@ -266,12 +385,22 @@ services:
REDIS_URL: redis://redis:6379/2
SECRET_KEY: ${GLITCHTIP_SECRET}
CELERY_WORKER_AUTOSCALE: "1,3"
# Письма и веб-хуки шлёт celery, то есть ИМЕННО этот контейнер, а не web.
# До этой правки почтовых переменных здесь не было вовсе: настройка одного
# glitchtip-web не дала бы ни одного отправленного письма — worker брал
# умолчания образа. Значения обязаны совпадать с web (см. комментарий там).
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru}
# Нужен для абсолютных ссылок внутри писем и веб-хуков: без него
# уведомление приходит со ссылкой в никуда.
GLITCHTIP_DOMAIN: https://errors.gendsgn.ru
restart: always
mem_limit: 384m
networks: [default]
caddy:
image: caddy:2
logging: *default-logging
restart: unless-stopped
ports:
- "80:80"
@ -310,6 +439,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}

View file

@ -21,7 +21,7 @@
| **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner |
| **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) |
| **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) |
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `AUTH_DB_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
| **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек |
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
@ -62,13 +62,14 @@
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
| `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** |
| `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** |
| `AUTH_DB_PASSWORD` | `backend/.env.runtime` | Пароль роли `auth_app` — БД `auth` на gendesign-postgres (единое хранилище доступов «Меры» и «Птицы»). Применяется через `ops/db-bootstrap/set_auth_app_password.sql` на деплое. Переменная задаётся на VPS вручную; пока не задана — шаг пропускается с warning'ом | **E** |
| `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** |
| `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |
| `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`)
@ -76,7 +77,6 @@
|---|---|---|
| `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** |
| `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** |
| `YANDEX_GEOCODER_API_KEY` | Yandex Geocoder (25k req/day) | **D** |
| `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** |
| `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) |
| `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** |
@ -146,14 +146,14 @@ bcrypt-хеши — односторонние, не plaintext-секреты,
3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`.
4. Vault entry.
### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`)
### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`)
**Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии).
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт).
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / DaData / Cian-аккаунт).
2. Где живёт:
- `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`.
- `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI).
- `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI).
3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein).
4. Vault entry.
@ -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-аутентификации.

View file

@ -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<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_user_id") ?? ""),
);
const [adminToken] = useState<string>(() =>
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<TabId>(() => {
@ -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 */}
<div style={{ marginBottom: 16 }}>
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
{!!adminToken && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
>
User ID (для профилей):
</label>
<input
type="text"
value={profileUserId}
placeholder="user-abc"
style={{
padding: "4px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 6,
width: 180,
}}
onChange={(e) => {
setProfileUserId(e.target.value);
if (typeof window !== "undefined") {
localStorage.setItem("admin_user_id", e.target.value);
}
}}
/>
</div>
)}
<WeightProfilePanel
currentWeights={currentWeights}
onWeightsChange={handleWeightsChange}
userId={profileUserId || undefined}
adminToken={adminToken || undefined}
/>
{/* Recalculation indicator shown while re-analyze is in-flight after
weights change (data already loaded, pendingWeightsChange set). */}

View file

@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import {
AnalyzeWeightsContext,
adaptEgrn,
useParcelAnalyzeQuery,
} from "@/lib/site-finder-api";
import type { PoiCategoryKey } from "@/lib/api/weightProfiles";
import type {
ParcelAnalysis,
PendingConceptProgram,
@ -29,7 +34,40 @@ interface Props {
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
/**
* Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в
* контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние
* нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы
* контекст «сверху», то есть null, и страница разъехалась бы на два разных
* анализа свой у шапки, свой у секций.
*
* null = веса не применяли запрос как раньше, без тела.
*/
export function AnalysisPageContent({ cad }: Props) {
const [appliedWeights, setAppliedWeights] = useState<Record<
PoiCategoryKey,
number
> | null>(null);
return (
<AnalyzeWeightsContext.Provider value={appliedWeights}>
<AnalysisPageBody
cad={cad}
appliedWeights={appliedWeights}
onWeightsApply={setAppliedWeights}
/>
</AnalyzeWeightsContext.Provider>
);
}
function AnalysisPageBody({
cad,
appliedWeights,
onWeightsApply,
}: Props & {
appliedWeights: Record<PoiCategoryKey, number> | null;
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}) {
const [horizon, setHorizon] = useState<number>(12);
const queryClient = useQueryClient();
@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
<GroupDivider label="Стройка и рынок" />
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
{/* 4. Рынок и конкуренты IMPLEMENTED in A7. Веса POI из §4.1
поднимаем сюда: «Применить» меняет ключ analyze-запроса скор
пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
<Section3SettingsAndCompetitors
cad={cad}
data={analysis}
weights={appliedWeights}
onWeightsApply={onWeightsApply}
/>
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
<Section5Atmosphere cad={cad} />

View file

@ -0,0 +1,159 @@
/**
* #2790 п.1 «Применить» у весов POI в §4.1 ничего не применяло.
*
* Состояние весов жило в `Section31Settings` и читалось только обратно в ту же
* панель: до `/analyze` оно не доезжало никогда (слова `weights` в
* AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал
* «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам.
*
* Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и
* настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес карты,
* прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй
* POST /analyze не случается вообще красный.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnalysisPageContent } from "../AnalysisPageContent";
// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут
// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) —
// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста.
vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null }));
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
GateVerdictBanner: () => null,
}));
vi.mock("@/components/site-finder/HorizonSelector", () => ({
HorizonSelector: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
Section1ParcelInfo: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
Section2NetworksUtilities: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
Section4Estimate: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
Section5Atmosphere: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
Section6Forecast: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({
Section7Concept: () => null,
}));
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
SectionAlternatives: () => null,
}));
vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({
BestLayoutsBlock: () => null,
}));
const CAD = "66:41:0702017:131";
const ANALYSIS = {
cad_num: CAD,
score: 18.91,
district: { district_name: "Чкаловский" },
egrn: null,
competitors: [],
};
/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */
const analyzeBodies: Array<Record<string, unknown> | undefined> = [];
const fetchMock = vi.fn<typeof fetch>();
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
analyzeBodies.length = 0;
fetchMock.mockReset();
fetchMock.mockImplementation(async (input, init) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/analyze")) {
const raw = init?.body;
analyzeBodies.push(
typeof raw === "string"
? (JSON.parse(raw) as Record<string, unknown>)
: undefined,
);
return jsonResponse(ANALYSIS);
}
if (url.includes("/api/v1/me")) {
return jsonResponse({
username: "admin",
role: "admin",
allowed_paths: ["/**"],
deny_paths: [],
});
}
if (url.includes("/weight-profiles")) {
return jsonResponse([]);
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<AnalysisPageContent cad={CAD} />
</QueryClientProvider>,
);
}
/** Ползунок конкретной категории по подписи строки в панели весов. */
function sliderFor(label: string): HTMLInputElement {
const row = screen.getByText(label).closest("div");
if (!row) throw new Error(`не нашёл строку ползунка «${label}»`);
const input = row.querySelector('input[type="range"]');
if (!input) throw new Error(`в строке «${label}» нет ползунка`);
return input as HTMLInputElement;
}
describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => {
it("отправляет ползунки в тело повторного analyze", async () => {
renderPage();
// Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе,
// бэкенд считает по системным. Это же и baseline для «стало другим».
await waitFor(() => expect(analyzeBodies.length).toBe(1));
expect(analyzeBodies[0]).toBeUndefined();
fireEvent.click(await screen.findByText("POI Веса"));
fireEvent.change(sliderFor("Парки"), { target: { value: "3" } });
fireEvent.change(sliderFor("Трамвайные ост. ()"), {
target: { value: "-2" },
});
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
// Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что
// выставлены ползунками. До фикса второго запроса не было — красный здесь.
await waitFor(() => expect(analyzeBodies.length).toBe(2));
const applied = analyzeBodies[1]?.weights as Record<string, number>;
expect(applied.park).toBe(3);
expect(applied.tram_stop).toBe(-2);
// Нетронутые категории уходят как есть — бэкенд мержит поверх системных,
// но панель отправляет полный набор, чтобы ответ совпадал с ползунками.
expect(applied.school).toBe(1.5);
});
});

View file

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

View file

@ -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)}
/>
<CascadeRow
label="НДС (паркинг)"
label="НДС (паркинг + коммерция)"
value={formatMoneyCompact(financial.vat_rub)}
/>
<CascadeRow

View file

@ -1,5 +0,0 @@
// ScoreCard is superseded by the tabbed dashboard in page.tsx.
// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab.
// File kept to avoid breaking any external imports; exports an empty stub.
export {};

View file

@ -3,12 +3,14 @@
import { useCallback, useState } from "react";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { useMe } from "@/lib/useMe";
import {
POI_CATEGORIES,
POI_DEFAULT_WEIGHTS,
POI_LABELS,
POI_WEIGHT_MAX,
POI_WEIGHT_MIN,
SYSTEM_PROFILE_USER_ID,
useCreateProfile,
useWeightProfiles,
type PoiCategoryKey,
@ -29,13 +31,6 @@ interface Props {
weights: Record<PoiCategoryKey, number>,
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 +61,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 +79,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 ────────────────────────────────────────────────────────────────
@ -113,11 +111,22 @@ export function WeightProfilePanel({
}
function handleApply() {
onWeightsChange({ ...draft }, selectedProfileId);
// Системный пресет не адресуем через profile_id: resolve_weights() ищет
// профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не
// текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и
// отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для
// пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса,
// а они ровно те, что на ползунках.
const selected = profiles.find((p) => p.id === selectedProfileId) ?? null;
const addressableId =
selected && selected.user_id !== SYSTEM_PROFILE_USER_ID
? selected.id
: null;
onWeightsChange({ ...draft }, addressableId);
}
const handleSaveProfile = useCallback(async () => {
if (!canUseCrud || !userId || !adminToken) {
if (!canUseCrud || !userId) {
setShowSaveDialog(false);
return;
}
@ -136,15 +145,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 +243,7 @@ export function WeightProfilePanel({
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
>
Профиль:
</label>
@ -268,6 +270,7 @@ export function WeightProfilePanel({
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.profile_name}
{p.user_id === SYSTEM_PROFILE_USER_ID ? " · пресет" : ""}
{p.is_default ? " ★" : ""}
</option>
))}
@ -278,10 +281,13 @@ export function WeightProfilePanel({
</div>
)}
{/* Hint when no crud */}
{/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
Caddy basic_auth) остаётся для dev-запуска без прокси, где
/api/v1/me отвечает 401. Текст называет причину, а не действие:
вводить тут больше нечего. */}
{!canUseCrud && (
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
Укажите User ID и Admin Token для сохранения профилей.
Пользователь не определён сохранение профилей недоступно.
</p>
)}
@ -362,6 +368,9 @@ export function WeightProfilePanel({
}}
>
<SectionLabel>Новый профиль</SectionLabel>
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
Сохранится для пользователя {userId} другие его не увидят.
</p>
<input
autoFocus
type="text"

View file

@ -0,0 +1,151 @@
/**
* #2782 CRUD профилей весов был недостижим никому без DevTools.
*
* Панель отпирала сохранение/загрузку профилей значением `admin_token` из
* localStorage. Записывать его было нечем: инпут удалили в #442. Сервер этот
* заголовок к тому моменту уже не читал (#437, остатки убраны в #2775) то есть
* фича стояла за признаком, который ничего не решал.
*
* Тесты ниже пиннят три вещи, каждая из которых на старом коде красная:
* 1) панель берёт владельца профилей из /api/v1/me и включает CRUD без пропсов;
* 2) в запросы CRUD не уходит X-Admin-Token;
* 3) `profileId` не уходит в /analyze без `profileUserId` (иначе бэкенд молча
* считает по системным весам, рапортуя source="profile").
*/
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WeightProfilePanel } from "../WeightProfilePanel";
import { buildAnalyzeOptions } from "@/hooks/useSiteAnalysis";
import { POI_DEFAULT_WEIGHTS } from "@/lib/api/weightProfiles";
const fetchMock = vi.fn<typeof fetch>();
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(
<QueryClientProvider client={client}>
<WeightProfilePanel
currentWeights={{ ...POI_DEFAULT_WEIGHTS }}
onWeightsChange={() => {}}
/>
</QueryClientProvider>,
);
}
/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
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,
});
});
});

View file

@ -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 (
<nav
aria-label="Breadcrumb"
style={{
display: "flex",
alignItems: "center",
gap: 4,
flexWrap: "wrap",
minWidth: 0,
flex: 1,
}}
>
{/* SiteFinder root */}
<Link
href="/site-finder"
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
whiteSpace: "nowrap",
}}
>
SiteFinder
</Link>
<ChevronRight
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
aria-hidden
/>
{/* Cad number */}
<Link
href={`/site-finder?selected=${encodeURIComponent(cadNum)}`}
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 260,
}}
title={cadNum}
>
{cadNum}
</Link>
<ChevronRight
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
aria-hidden
/>
{/* Current page */}
<span
aria-current="page"
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--fg-primary)",
whiteSpace: "nowrap",
}}
>
Анализ
</span>
</nav>
);
}

View file

@ -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<string>(ALL_SECTION_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(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<string>();
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<HTMLAnchorElement>,
targetId: string,
) {
e.preventDefault();
const el = document.getElementById(targetId);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
setActiveId(targetId);
}
return (
<aside
style={{
width: 240,
flexShrink: 0,
background: "var(--bg-card)",
borderRight: "1px solid var(--border-card)",
padding: "16px 12px",
display: "flex",
flexDirection: "column",
gap: 4,
position: "sticky",
top: 56,
height: "calc(100vh - 56px)",
overflowY: "auto",
}}
>
{/* Section label */}
<p
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: "0 0 8px",
padding: "0 4px",
}}
>
Навигация
</p>
{/* Nav items */}
{NAV_SECTIONS.map((section) => {
const isParentActive =
activeId === section.id ||
section.sub?.some((s) => s.id === activeId);
return (
<div key={section.id}>
<a
href={`#${section.id}`}
onClick={(e) => handleAnchorClick(e, section.id)}
style={{
display: "block",
padding: "7px 10px",
borderRadius: 8,
fontSize: 13,
fontWeight: isParentActive ? 600 : 400,
color: isParentActive ? "var(--accent)" : "var(--fg-secondary)",
background: isParentActive
? "var(--accent-soft)"
: "transparent",
textDecoration: "none",
transition: "background 100ms, color 100ms",
lineHeight: 1.4,
}}
>
{section.label}
</a>
{/* Sub-sections */}
{section.sub && (
<div
style={{
paddingLeft: 12,
marginTop: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{section.sub.map((sub) => {
const isSubActive = activeId === sub.id;
return (
<a
key={sub.id}
href={`#${sub.id}`}
onClick={(e) => handleAnchorClick(e, sub.id)}
style={{
display: "block",
padding: "5px 10px",
borderRadius: 6,
fontSize: 12,
fontWeight: isSubActive ? 600 : 400,
color: isSubActive
? "var(--accent)"
: "var(--fg-tertiary)",
background: isSubActive
? "var(--accent-soft)"
: "transparent",
textDecoration: "none",
transition: "background 100ms, color 100ms",
lineHeight: 1.4,
}}
>
{sub.label}
</a>
);
})}
</div>
)}
</div>
);
})}
{/* Sources footer */}
<div
style={{
marginTop: "auto",
paddingTop: 16,
borderTop: "1px solid var(--border-soft)",
}}
>
<p
style={{
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: "0 0 6px",
padding: "0 4px",
}}
>
Источники
</p>
{[
{ label: "Росреестр / ЕГРН", href: "https://rosreestr.gov.ru" },
{ label: "НСПД", href: "https://nspd.gov.ru" },
{ label: "2ГИС / OSM", href: "https://2gis.ru" },
].map(({ label, href }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 4px",
fontSize: 11,
color: "var(--fg-tertiary)",
textDecoration: "none",
}}
>
<ExternalLink size={10} strokeWidth={1.5} />
{label}
</a>
))}
</div>
</aside>
);
}

View file

@ -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 (
<div
style={{
opacity: stale ? 0.55 : 1,
transition: "opacity 150ms linear",
}}
>
{/* ТЭП */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Общая площадь (GFA)"
value={formatInt(teap.total_floor_area_sqm)}
unit="м²"
hint="Поэтажная площадь всех корпусов = пятно застройки × этажность."
/>
<KpiCard
label="Продаваемая площадь"
value={formatInt(teap.residential_area_sqm)}
unit="м²"
hint="Жилая к продаже = (GFA нежилой 1-й этаж) × коэффициент эффективности класса."
/>
<KpiCard
label="Квартир"
value={formatInt(teap.apartments_count)}
unit="шт"
hint="Продаваемая площадь ÷ средняя площадь квартиры класса."
/>
<KpiCard
label="КСИТ — факт / цель"
value={`${formatFar(teap.density)} / ${formatFar(farTarget)}`}
delta={{
value: ksitOver
? "Факт превышает регламентный потолок"
: "В пределах регламента",
positive: ksitOver ? false : true,
}}
hint="КСИТ (коэффициент строительного использования) = надземная GFA ÷ площадь участка. Цель — предельный max_far по регламенту НСПД."
/>
</div>
{/* Финмодель */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
marginTop: 12,
}}
>
<KpiCard
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
hint="Продаваемая площадь × цена продажи м² (+ машиноместа и нежилой 1-й этаж по ценам класса)."
/>
<KpiCard
label="Себестоимость"
value={formatMoneyCompact(financial.cost_rub)}
hint="Строительство (GFA × удельная по классу) + ПИР, сети, услуги застройщика, непредвиденные, маркетинг + стоимость земли."
/>
<KpiCard
label="Чистая прибыль"
value={formatMoneyCompact(financial.net_profit_rub)}
delta={{
value:
netPositive === true
? "Положительная (после НДС и налога на прибыль)"
: netPositive === false
? "Отрицательная (после НДС и налога на прибыль)"
: "Нулевая",
positive: netPositive,
}}
hint="Выручка себестоимость НДС на нежилое налог на прибыль 25%. Жильё по ДДУ от НДС освобождено."
/>
<KpiCard
label="ROI на затраты"
value={formatPct(financial.roi)}
delta={{
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
positive: null,
}}
hint="Чистая прибыль ÷ себестоимость. Маржа считается от выручки."
/>
<KpiCard
label="NPV (DCF)"
value={formatMoneyCompact(financial.npv_rub)}
delta={{
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
positive:
financial.npv_rub > 0
? true
: financial.npv_rub < 0
? false
: null,
}}
hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct(
financial.discount_rate_used,
)} годовых).`}
/>
<KpiCard
label="IRR (DCF, годовой)"
value={formatPct(financial.irr)}
delta={{
value: financial.irr_is_proxy
? "Оценочный (вырожденный поток, не DCF)"
: "Дисконтированный денежный поток",
positive: financial.irr_is_proxy
? null
: financial.irr > financial.discount_rate_used
? true
: false,
}}
hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)."
/>
<KpiCard
label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
delta={{ value: priceSourceCaption(financial), positive: null }}
hint="Цена, заложенная в выручку: медиана объявлений Objective по району (источник указан выше)."
/>
</div>
</div>
);
}
// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
function SkeletonGrid() {
const cells = Array.from({ length: 7 });
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
aria-hidden="true"
>
{cells.map((_, i) => (
<div
key={i}
style={{
height: 92,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
}}
/>
))}
</div>
);
}
// ── Component ───────────────────────────────────────────────────────────────────
interface Props {
/**
* The current massing program (Σ footprint × floors + context), mapped by
* Section7Concept from the 3D scene's computeModel result. `null` until the
* scene has fired its first onModelChange (or when geometry is missing).
*/
program: MassingProgram | null;
/** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
farTarget: number;
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
ksitOver: boolean;
/**
* True when the parcel is regulatorily constrained for МКД (gate-blocked /
* non-residential / ЗОУИТ-СЗЗ) drives the honest «условный расчёт» caveat
* below the strip alongside the negative-economics case.
*/
gateConstrained?: boolean;
}
export function MassingEconomics({
program,
farTarget,
ksitOver,
gateConstrained = false,
}: Props) {
const recompute = useRecomputeMassing();
// Last successful result kept locally so a failed/stale recompute never blanks
// the panel (last-good values stay on screen).
const [result, setResult] = useState<MassingRecomputeOutput | null>(null);
const [errored, setErrored] = useState(false);
// Monotonic request id → latest-wins: a response is applied only if it belongs
// to the most recently issued request, so out-of-order arrivals are dropped.
const reqIdRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable JSON key so we only recompute when the program actually changes
// (not on every parent re-render that hands an equal-but-new object).
const programKey = program ? JSON.stringify(program) : null;
useEffect(() => {
if (!program) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const id = ++reqIdRef.current;
recompute
.mutateAsync(program)
.then((out) => {
if (id !== reqIdRef.current) return; // stale — a newer request won.
setResult(out);
setErrored(false);
})
.catch(() => {
if (id !== reqIdRef.current) return; // stale failure — ignore.
setErrored(true); // keep last-good `result`.
});
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// recompute is a stable mutation object; we key off the serialized program.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [programKey]);
// No program yet → nothing to show (parent gates this on geometry anyway).
if (!program) return null;
// First recompute in flight, no last-good value yet → skeleton.
if (!result) {
return (
<Section
title="Экономика по 3D-модели"
subtitle="Пересчёт ТЭП и финмодели по текущей массе застройки (этажность / секции) из 3D-модели слева."
>
<SkeletonGrid />
</Section>
);
}
// A fresher request is in flight over the last-good values.
const stale = recompute.isPending;
// Honest «условный расчёт» caveat: when the economics turn negative OR the
// parcel is regulatorily constrained (gate-blocked / ЗОУИТ-СЗЗ / нежилое),
// we say so plainly rather than presenting the figures as a viable project.
const economicsNegative =
result.financial.net_profit_rub < 0 || result.financial.npv_rub < 0;
const showConditionalNote = economicsNegative || gateConstrained;
return (
<Section
title="Экономика по 3D-модели"
subtitle="Пересчитывается вживую при изменении этажности / секций в 3D-модели слева. Цена продажи — из оценки участка, без рыночного DB-запроса; ТЭП синтезируется из массинг-программы."
>
<KpiGrid
teap={result.teap}
financial={result.financial}
farTarget={farTarget}
ksitOver={ksitOver}
stale={stale}
/>
{showConditionalNote ? (
<p
role="note"
style={{
margin: "12px 0 0",
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: 12,
lineHeight: "16px",
color: "var(--warn)",
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden="true"
style={{ flexShrink: 0, marginTop: 1 }}
/>
<span>
Расчёт условный: участок ограничен регламентом (см. блокеры выше)
и/или экономика отрицательна при текущих вводных. Измените
этажность, число секций или класс модель пересчитается.
</span>
</p>
) : null}
{errored ? (
<p
role="status"
style={{
margin: "12px 0 0",
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
color: "var(--warn)",
}}
>
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden="true" />
Не удалось пересчитать экономику по последнему изменению показаны
предыдущие значения. Измените параметры ещё раз для повторного
расчёта.
</p>
) : null}
</Section>
);
}

View file

@ -28,6 +28,10 @@ interface Props {
cad: string;
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
data: ParcelAnalysis;
/** Уже применённые POI-веса; null = ничего не применяли (системные). */
weights: Record<PoiCategoryKey, number> | null;
/** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}
interface FilterState {
@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) {
function Section31Settings({
filters,
onFiltersChange,
weights,
onWeightsApply,
}: {
filters: FilterState;
onFiltersChange: (f: FilterState) => void;
weights: Record<PoiCategoryKey, number> | null;
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
}) {
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
() => ({ ...POI_DEFAULT_WEIGHTS }),
);
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
onFiltersChange({ ...filters, [key]: !filters[key] });
}
function handleWeightsChange(
newWeights: Record<PoiCategoryKey, number>,
_profileId: number | null,
) {
setWeights(newWeights);
}
const chips: Array<{
key: keyof Omit<FilterState, "radiusKm">;
label: string;
@ -136,8 +133,8 @@ function Section31Settings({
margin: "4px 0 0",
}}
>
Фильтры применяются к конкурентам локально без повторного запроса к
бэкенду
Радиус и фильтры применяются к конкурентам локально. Веса POI
пересчёт анализа на бэкенде по кнопке «Применить»
</p>
</div>
@ -259,8 +256,8 @@ function Section31Settings({
Профиль весов POI
</div>
<WeightProfilePanel
currentWeights={weights}
onWeightsChange={handleWeightsChange}
currentWeights={weights ?? POI_DEFAULT_WEIGHTS}
onWeightsChange={onWeightsApply}
/>
</div>
</div>
@ -769,7 +766,12 @@ function applyFilters(
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
export function Section3SettingsAndCompetitors({
cad,
data,
weights,
onWeightsApply,
}: Props) {
const [filters, setFilters] = useState<FilterState>({
radiusKm: 2,
onlyUnderConstruction: false,
@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
<StageDetails>
{/* Sub-sections */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<Section31Settings filters={filters} onFiltersChange={setFilters} />
<Section31Settings
filters={filters}
onFiltersChange={setFilters}
weights={weights}
onWeightsApply={onWeightsApply}
/>
{/* Competitor table — moved before 3.2/3.3 for context */}
{filteredCompetitors.length > 0 && (

View file

@ -1,104 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Building2 } from "lucide-react";
// ── Helpers ───────────────────────────────────────────────────────────────────
function getStoredOrgId(): string | null {
// Guard against SSR — localStorage not available on server
if (typeof window === "undefined") return null;
try {
return localStorage.getItem("gd_org_id");
} catch {
return null;
}
}
function orgInitials(orgId: string): string {
// Build a 2-letter monogram from org ID string
const parts = orgId
.toUpperCase()
.replace(/[^A-ZА-Я0-9]/gu, " ")
.split(" ")
.filter(Boolean);
if (parts.length === 0) return "??";
if (parts.length === 1) return parts[0].slice(0, 2);
return parts[0][0] + parts[1][0];
}
// ── Component ─────────────────────────────────────────────────────────────────
export function UserAvatar() {
const [orgId, setOrgId] = useState<string | null>(null);
// Hydration-safe: read localStorage after mount
useEffect(() => {
setOrgId(getStoredOrgId());
}, []);
const displayLabel = orgId ?? "Demo Org";
const initials = orgId ? orgInitials(orgId) : "DO";
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexShrink: 0,
}}
title={displayLabel}
>
{/* Avatar circle */}
<div
aria-hidden
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "var(--accent-soft)",
border: "1px solid var(--border-card)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{orgId ? (
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--accent)",
letterSpacing: "0.02em",
}}
>
{initials}
</span>
) : (
<Building2
size={14}
strokeWidth={1.5}
style={{ color: "var(--accent)" }}
/>
)}
</div>
{/* Org name — hidden on narrow viewports via maxWidth trick */}
<span
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary)",
maxWidth: 140,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{displayLabel}
</span>
</div>
);
}

View file

@ -1,22 +0,0 @@
"use client";
/**
* PticaPlaceholderPanel honest "В разработке" panel for cockpit tabs/sections
* that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare).
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
interface Props {
label: string;
hint?: string;
}
export function PticaPlaceholderPanel({ label, hint }: Props) {
return (
<div className={`${styles.panel} ${styles.placeholderPanel}`}>
<div className={styles.soon}>{label}</div>
{hint && <p>{hint}</p>}
</div>
);
}

View file

@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
// /fetch-status every 2s, re-triggers analyze когда status=ready.
export type FetchStatus =
| "ready"
| "fetching"
| "failed"
| "not_in_nspd"
| "invalid_format";
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
export interface FetchStatusResponse {
status: FetchStatus;
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
weights?: Record<string, number> | null;
}
/**
* Собрать options для POST /analyze из состояния панели весов POI.
*
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
* #2782: `profile_id=1` без `profile_user_id` `tram_stop 0.5` вместо `0.4` из
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
*
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me 401) шлём inline
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
*/
export function buildAnalyzeOptions(
weights: Record<string, number>,
profileId: number | null,
profileUserId: string,
): AnalyzeOptions {
if (profileId != null && profileUserId) {
return { profileId, profileUserId };
}
if (profileUserId) {
return { profileUserId, weights };
}
return { weights };
}
/**
* Custom hook для analyze flow с graceful on-demand fetch fallback.
*

View file

@ -16,6 +16,7 @@
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
* timers, and assert on abort behaviour + the happy path.
*/
import { renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Capture the options passed to useQuery ───────────────────────────────────
@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42";
* polling queryFn. Reads `captured.options` via a fresh binding so TS control-
* flow doesn't pin it (the hook mutates it opaquely through the mock).
*
* `useQuery` is fully mocked (it just records its options, no React state), so
* the rules-of-hooks invariant does not apply to this call disable locally.
* Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые
* веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет
* dispatcher'а «Cannot read properties of null». `useQuery` по-прежнему
* замокан и просто записывает options; провайдера над хуком нет, значит
* контекст = null, то есть ровно тот случай «весов не применяли», который этот
* тест и гоняет.
*/
function getQueryFn(): CapturedQueryOptions["queryFn"] {
// eslint-disable-next-line react-hooks/rules-of-hooks
useParcelAnalyzeQuery(CAD, 12);
renderHook(() => useParcelAnalyzeQuery(CAD, 12));
const options = captured.options;
if (options === null) throw new Error("useQuery options not captured");
return options.queryFn;

View file

@ -27,14 +27,18 @@ export interface WeightProfileCreate {
description?: string | null;
}
export interface WeightProfileUpdate {
profile_name?: string;
weights?: Record<string, number>;
is_default?: boolean;
description?: string | null;
}
// ── Constants ─────────────────────────────────────────────────────────────────
/**
* Владелец системных пресетов (Эконом / Комфорт / Бизнес) mirrors
* `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py.
* Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`:
* `resolve_weights()` ищет профиль в области владельца, у чужого пользователя
* его не найдёт и молча вернёт системные веса с ответом `source="profile"`
* (#2782). Их веса уходят в analyze inline см. WeightProfilePanel.
*/
export const SYSTEM_PROFILE_USER_ID = "__system__";
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
@ -91,35 +95,44 @@ export const POI_WEIGHT_MAX = 3;
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
function profilesHeaders(adminToken: string): HeadersInit {
return { "X-Admin-Token": adminToken };
}
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
// с корректным токеном, с мусорным и без заголовка вовсе.
//
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
// (подставленный клиентом X-Authenticated-User туда же не проходит);
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
// ── Hooks ─────────────────────────────────────────────────────────────────────
/** List all weight profiles for a given user_id. */
export function useWeightProfiles(userId: string, adminToken: string) {
/**
* Профили пользователя + системные пресеты (#2790).
*
* `include_system=true` домешивает в конец списка три общих пресета (Эконом /
* Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`).
* Без него у пользователя без своих профилей дропдаун пустой пресеты лежали в
* проде с 16.05.2026 и не были видны никому.
*/
export function useWeightProfiles(userId: string) {
return useQuery<WeightProfile[]>({
queryKey: ["weight-profiles", userId],
queryFn: () =>
apiFetch<WeightProfile[]>(
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
{
headers: profilesHeaders(adminToken),
},
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
),
enabled: !!userId && !!adminToken,
enabled: !!userId,
});
}
/** Create a new weight profile. Invalidates the list query on success. */
export function useCreateProfile(adminToken: string) {
export function useCreateProfile() {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileCreate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(BASE_PATH, {
method: "POST",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
}),
onSuccess: (_, variables) => {
@ -130,43 +143,9 @@ export function useCreateProfile(adminToken: string) {
});
}
/** Update an existing weight profile by id. */
export function useUpdateProfile(
userId: string,
profileId: number,
adminToken: string,
) {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "PUT",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
},
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
},
});
}
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
export function useDeleteProfile(userId: string, adminToken: string) {
const qc = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: (profileId) =>
apiFetch<void>(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "DELETE",
headers: profilesHeaders(adminToken),
},
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
},
});
}
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).

View file

@ -330,7 +330,7 @@ export interface MassingRecomputeOutput {
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
*
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
* with latest-wins sequencing handled by the caller (see MassingEconomics).
* with latest-wins sequencing handled by the caller.
*/
export function useRecomputeMassing() {
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({

View file

@ -7,7 +7,7 @@
* и иметь один безопасный канал кодирования кадастрового номера.
*
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
* NspdOpportunityBlock, ParcelDrawer.
*/
/**

View file

@ -9,6 +9,7 @@
*/
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { createContext, useContext } from "react";
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
import { abortableSleep } from "@/lib/abortableSleep";
import type {
@ -503,9 +504,36 @@ export interface PoiScoreResponse {
const ANALYZE_POLL_INTERVAL_MS = 2000;
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
/**
* Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли запрос
* уходит без тела, как и раньше (бэкенд считает по системным весам).
*
* Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)`
* зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) все они делят один
* ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос.
* Если веса доедут только до части из них, ключи разойдутся: половина страницы
* покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды.
* Контекст держит всех потребителей ключа на одном значении по построению
* забыть прокинуть проп в новую секцию нельзя.
*/
export const AnalyzeWeightsContext = createContext<Record<
string,
number
> | null>(null);
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
const weights = useContext(AnalyzeWeightsContext);
// Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем.
// null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до
// #2790, кэш не сбрасывается на ровном месте.
const weightsKey = weights
? JSON.stringify(Object.entries(weights).sort())
: null;
return useQuery({
queryKey: ["parcel-analyze", cad, horizon],
// Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI
// мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт.
queryKey: ["parcel-analyze", cad, horizon, weightsKey],
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
// it through the POST/GET fetches and check it before each poll iteration so
@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
cad,
)}/analyze?horizon=${horizon}`;
// Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id:
// тело запроса == ползункам панели, и ответ рапортует source="inline" —
// расхождению между показанными весами и посчитанным скором взяться
// неоткуда (в отличие от profile_id, см. #2782).
const analyzeInit: RequestInit = weights
? { method: "POST", signal, body: JSON.stringify({ weights }) }
: { method: "POST", signal };
// First request — POST /analyze. apiFetchWithStatus surfaces the 202
// Accepted code instead of treating it as a successful payload.
const first = await apiFetchWithStatus<
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
>(analyzeUrl, { method: "POST", signal });
>(analyzeUrl, analyzeInit);
// 200 → geometry was cached, full analysis is ready.
if (first.status === 200) {
@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
// rather than returning the stub (symmetry with the first request).
const second = await apiFetchWithStatus<
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
>(analyzeUrl, { method: "POST", signal });
>(analyzeUrl, analyzeInit);
if (second.status === 200) {
return second.body as ParcelAnalyzeResponse;
}

View file

@ -0,0 +1,67 @@
-- Создание БД `auth` — единого хранилища доступов «Меры» и «Птицы» (идемпотентно).
--
-- Applied by .forgejo/workflows/deploy.yml ПЕРЕД миграциями data/sql/auth/*.sql:
-- docker compose ... exec -T postgres psql -U "$POSTGRES_USER" -d postgres \
-- -v ON_ERROR_STOP=on < ops/db-bootstrap/create_auth_db.sql
-- Подключение обязательно к БД `postgres`: нельзя создать базу, находясь в ней самой.
--
-- ПОЧЕМУ ЭТО НЕ МИГРАЦИЯ:
-- CREATE DATABASE запрещён внутри транзакционного блока, а .claude/rules/sql.md требует
-- от каждого файла в data/sql обёртки BEGIN/COMMIT. Плюс миграции `auth` по определению
-- выполняются уже ВНУТРИ БД `auth` — то есть создать её собой они не могут. Отсюда
-- отдельный bootstrap-шаг, по образцу scripts/bootstrap_glitchtip.sh (там так же
-- заводится вторая БД на этом же сервере).
--
-- ПОЧЕМУ \gexec, А НЕ DO-БЛОК:
-- DO-блок — это функция, она выполняется внутри транзакции, значит CREATE DATABASE в ней
-- недопустим. \gexec строит текст команды на стороне клиента и отправляет её отдельным
-- стейтментом. Если WHERE NOT EXISTS отфильтровал строку, \gexec не получает ничего и
-- молча ничего не делает — это и даёт идемпотентность без ошибки на повторном прогоне.
-- ON_ERROR_STOP=on распространяется и на команды, выполненные через \gexec.
--
-- ВЛАДЕЛЕЦ БД — $POSTGRES_USER (суперюзер кластера), НЕ auth_app. Владелец объекта имеет на
-- него все права в обход GRANT'ов; если бы БД и таблицы принадлежали прикладной роли,
-- точечные гранты в data/sql/auth/002_auth_app_role.sql были бы декорацией. Роль auth_app
-- создаётся миграцией 002 и получает только нужные DML-права.
--
-- TEMPLATE template0 — сознательно, а не template1 (шаблон по умолчанию): template0
-- гарантированно пуст и неизменяем, а в template1 любой может доустановить расширения или
-- объекты, и они молча окажутся в хранилище паролей. На образе postgis:16-3.4 сегодня
-- postgis лежит в template_postgis, а template1 чист (проверено локально на том же образе),
-- но полагаться на это как на инвариант незачем — template0 снимает вопрос навсегда.
-- ENCODING 'UTF8' указан явно (кластер и так UTF8 — вся кириллица gendesign лежит в нём),
-- чтобы кодировка хранилища логинов не зависела от того, с какими аргументами когда-нибудь
-- пересоздадут кластер.
--
-- Пароля в этом файле нет и быть не может: роль создаётся passwordless в миграции 002,
-- пароль ставится отдельным шагом из env (ops/db-bootstrap/set_auth_app_password.sql).
SELECT 'CREATE DATABASE auth TEMPLATE template0 ENCODING ''UTF8'';'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'auth')
\gexec
-- Единственная преграда для «любая login-роль кластера (glitchtip, tradein_fdw_reader,
-- gendesign_reader) открывает сессию в хранилище паролей»: по умолчанию PostgreSQL выдаёт
-- CONNECT роли PUBLIC при создании БД.
--
-- ДУБЛЬ С data/sql/auth/002_auth_app_role.sql — НАМЕРЕННЫЙ, не копипаста. Инвариант держится
-- в двух местах, потому что у файлов разный жизненный цикл:
-- * здесь (bootstrap) — ради ПЕРЕПРИМЕНЯЕМОСТИ: этот файл гоняется на КАЖДОМ деплое, там же,
-- где создаётся БД. Если `auth` восстановят из дампа или пересоздадут в обход миграций,
-- база появится с дефолтным PUBLIC-CONNECT, а 002 уже числится применённой в
-- _schema_migrations и второй раз не выполнится — REVOKE молча не вернётся.
-- * в 002 — ради САМОДОСТАТОЧНОСТИ миграции: применённая на пустую БД (scratch/staging,
-- ручной psql -f) она обязана давать полный периметр прав без чтения bootstrap-файлов.
-- Удалять любую из двух копий нельзя: каждая закрывает сценарий, который другая не покрывает.
--
-- Выполнимо из подключения к БД `postgres` (мы именно в ней): права на объект DATABASE живут
-- в pg_database.datacl — это общий на кластер каталог, не локальный для БД, в отличие от
-- грантов на таблицы/схемы. Проверено эмпирически на postgis:16-3.4 (REVOKE из сессии в
-- `postgres` по другой БД убирает `=Tc/` из datacl, has_database_privilege('public', …,
-- 'CONNECT') → false). Команда идемпотентна — повторный прогон бесплатен.
REVOKE ALL ON DATABASE auth FROM PUBLIC;
COMMENT ON DATABASE auth IS
'Единое хранилище доступов: «Мера» (trade-in) и «Птица» (Site Finder). Схема — '
'data/sql/auth/*.sql, применяется отдельным циклом миграций в .forgejo/workflows/deploy.yml '
'(таблица _schema_migrations живёт внутри этой же БД).';

View file

@ -0,0 +1,50 @@
-- Set auth_app password from env.
-- Applied by .forgejo/workflows/deploy.yml after auth DB migrations:
-- psql -v pw="$AUTH_DB_PASSWORD" < ops/db-bootstrap/set_auth_app_password.sql
-- Источник переменной: AUTH_DB_PASSWORD из /opt/gendesign/backend/.env.runtime (chmod 600,
-- вне git). Зеркало паттерна ops/db-bootstrap/set_tradein_fdw_password.sql и
-- tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql.
--
-- Idempotent: ALTER если роль существует, NOTICE и продолжает если нет (миграция
-- data/sql/auth/002_auth_app_role.sql могла ещё не примениться на первом деплое).
-- Пароль НИКОГДА не хранится в этом файле или в git — только имя переменной.
--
-- Format %L экранирует пароль как SQL string literal — безопасно даже с кавычками.
--
-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted блока ($$...$$)
-- — это правило psql, не bug. Поэтому password передаём в DO через сессионный GUC
-- (set_config), который psql интерполирует ВНЕ dollar quote, и читаем внутри через
-- current_setting(). По той же причине файл подаётся через stdin, а НЕ через `psql -c`.
-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на
-- "syntax error at or near ':'" именно на этом.
--
-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное значение. Без
-- `\o /dev/null` psql напечатал бы пароль на stdout → leak в Forgejo Actions deploy logs
-- (retained, visible всем с repo read access). Поэтому оба set_config обёрнуты в
-- `\o /dev/null` / `\o` — глушится только их вывод, NOTICE из DO block (сигнал
-- идемпотентности) остаётся видимым.
--
-- Rollback path: НЕ revert этого файла (вернёт сломанный :'pw' внутри $$). Корректный
-- rollback — unset AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime на VPS, deploy.yml
-- тогда пропустит этот шаг полностью (роль останется без пароля = логин по паролю невозможен).
\o /dev/null
SELECT set_config('app.auth_pw', :'pw', false);
\o
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN
EXECUTE format('ALTER ROLE auth_app WITH PASSWORD %L', current_setting('app.auth_pw'));
RAISE NOTICE 'auth_app password set';
ELSE
RAISE NOTICE 'auth_app role missing — migration data/sql/auth/002_auth_app_role.sql not applied yet';
END IF;
END $$;
-- Clear GUC after use (defense-in-depth — не оставляем password в session state даже на
-- short connection). Same \o trick — set_config return value is empty string here, но лишний
-- row в stdout всё равно не нужен.
\o /dev/null
SELECT set_config('app.auth_pw', '', false);
\o

180
ops/docker-prune.sh Executable file
View file

@ -0,0 +1,180 @@
#!/usr/bin/env bash
# Периодическая уборка docker-мусора на прод-VM.
#
# ЗАЧЕМ. 2026-08-15 диск был занят на 76% (110 из 145 ГБ). Разбор показал 201
# том-сироту на 12.6 ГБ: 125 анонимных — каталоги данных PostgreSQL от тестовых
# прогонов CI, 76 — окружения задач Forgejo Actions. Прод-данных среди них не
# было ни одного.
#
# Корневая причина анонимных томов устранена отдельно: ci.yml и ci-tradein.yml
# снимали свой postgres через `docker rm -f` БЕЗ `-v`, поэтому контейнер уходил,
# а его том оставался. Теперь там `docker rm -fv`. Этот скрипт — страховка: он
# подбирает то, что runner не убрал за собой, и то, что накопилось раньше.
#
# ЧТО ИМЕННО УДАЛЯЕТСЯ (осознанно консервативно):
# - остановленные контейнеры старше 24ч;
# - висячие (dangling) образы старше 7 суток;
# - тома-сироты ТОЛЬКО двух известных форм: 64-символьный hex (анонимные) и
# FORGEJO-ACTIONS-TASK-*. Именованные тома со смыслом (gendesign_postgres_data,
# tradein-postgres-data, *_caddy_*, couchdb, redis и любые будущие) не трогаются
# НИКОГДА — даже если в моменте оказались отцеплены. Голый `docker volume prune`
# такой разницы не делает, поэтому здесь он намеренно не используется;
# - зависшие (running, но фактически брошенные) job-контейнеры раннера Forgejo
# Actions старше JOB_CONTAINER_MAX_AGE_HOURS. 2026-08-15: живьём на проде
# обнаружены три штуки в статусе Up 4-8 недель (раннер не убрал контейнер
# после прерванного/упавшего workflow — task killed, рестарт раннера в
# процессе job'а и т.п.). CI job физически не идёт сутками, поэтому что
# угодно с этим именем старше порога — гарантированный мусор, а не активная
# задача. `docker container prune` их не видит: тот фильтрует только
# status=exited, а эти контейнеры формально Up.
#
# Usage (cron на прод-VM; `bash <путь>`, а не голый путь — тогда снятый +x не ломает).
# Лог в /tmp — как у соседних записей в том же crontab (backup.sh, backfill'ы):
# 0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1
#
# Воскресенье 04:00 UTC — свободный слот: рядом 03:30 backup.sh, 04:30 backup
# tradein, 05:00+ backfill'ы.
#
# Раз в неделю достаточно: после устранения корневой причины (docker rm -fv в CI)
# копятся только тома runner'а. DRY_RUN=1 — показать, что удалится, не трогая.
set -euo pipefail
DRY_RUN="${DRY_RUN:-0}"
STOPPED_AGE="${STOPPED_AGE:-24h}"
IMAGE_AGE="${IMAGE_AGE:-168h}"
# Job CI никогда не идёт сутками — что угодно с именем job-контейнера раннера
# старше этого порога снимается безусловно (см. секцию 4 ниже).
JOB_CONTAINER_MAX_AGE_HOURS="${JOB_CONTAINER_MAX_AGE_HOURS:-24}"
log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }
disk_used_pct() { df --output=pcent / | tail -1 | tr -dc '0-9'; }
before_pct="$(disk_used_pct)"
log "старт: диск занят ${before_pct}%"
if [[ "$DRY_RUN" == "1" ]]; then
log "DRY_RUN=1 — только показываю"
fi
# ── 1. остановленные контейнеры ───────────────────────────────────────────────
if [[ "$DRY_RUN" == "1" ]]; then
# `until` поддерживает только `prune`, у `ls` его нет («invalid filter 'until'»),
# поэтому в dry-run считаем ВСЕ остановленные — это верхняя оценка.
log "остановленных контейнеров всего (удалятся только старше ${STOPPED_AGE}): \
$(docker container ls -aq --filter "status=exited" | wc -l)"
else
log "контейнеры: $(docker container prune -f --filter "until=${STOPPED_AGE}" \
2>&1 | tail -1)"
fi
# ── 2. висячие образы ─────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == "1" ]]; then
log "висячих образов: $(docker image ls -qf dangling=true | wc -l)"
else
log "образы: $(docker image prune -f --filter "until=${IMAGE_AGE}" 2>&1 | tail -1)"
fi
# ── 3. тома-сироты известных форм ─────────────────────────────────────────────
# Отбираем ПОИМЁННО, а не через `docker volume prune`: тот снёс бы любой
# отцепленный именованный том, включая боевой, если контейнер в моменте пересоздаётся.
mapfile -t candidates < <(
docker volume ls -qf dangling=true \
| grep -E '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true
)
skipped="$(docker volume ls -qf dangling=true \
| grep -vE '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true)"
if [[ -n "$skipped" ]]; then
log "ПРОПУЩЕНЫ (именованные, руками): $(echo "$skipped" | tr '\n' ' ')"
fi
if [[ "${#candidates[@]}" -eq 0 ]]; then
log "томов-сирот известных форм нет"
elif [[ "$DRY_RUN" == "1" ]]; then
log "томов к удалению: ${#candidates[@]}"
else
removed=0
for v in "${candidates[@]}"; do
if docker volume rm "$v" >/dev/null 2>&1; then
removed=$((removed + 1))
fi
done
log "томов удалено: ${removed} из ${#candidates[@]}"
fi
# ── 4. зависшие job-контейнеры раннера Forgejo Actions ───────────────────────
# Фильтр по имени — ЯКОРЬ на начало (`^FORGEJO-ACTIONS-TASK-`), не "содержит
# подстроку": `docker ps --filter name=` матчит как regex, поэтому `^...`
# гарантирует точный префикс, а не случайное совпадение где-то в середине
# имени сервисного контейнера. Долгоживущие сервисные контейнеры (forgejo,
# forgejo-runner*, gendesign-*, tradein-*, couchdb) под этот префикс не
# подпадают вообще — но ниже всё равно есть explicit-skip как страховка на
# случай будущего переименования, а не молчаливая надежда на то, что фильтр
# никогда не ошибётся.
#
# Возраст — из `docker inspect .State.StartedAt` (RFC3339), НЕ из текстового
# "Up 4 weeks" в выводе `docker ps`: тот округляет к ближайшей крупной единице
# и не пригоден для сравнения с порогом в часах.
mapfile -t job_ids < <(docker ps -aq --filter "name=^FORGEJO-ACTIONS-TASK-" || true)
job_removed=0
job_candidates=0
if [[ "${#job_ids[@]}" -eq 0 ]]; then
log "зависших job-контейнеров нет"
else
for id in "${job_ids[@]}"; do
name="$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##' || true)"
[[ -z "$name" ]] && continue
case "$name" in
forgejo | forgejo-runner* | gendesign-* | tradein-* | couchdb)
log "job-контейнеры: ПРОПУЩЕН сервисный '${name}' (не должен был пройти фильтр имени)"
continue
;;
esac
started_at="$(docker inspect --format '{{.State.StartedAt}}' "$id" 2>/dev/null || true)"
[[ -z "$started_at" || "$started_at" == "0001-01-01T00:00:00Z" ]] && continue
started_epoch="$(date -u -d "$started_at" +%s 2>/dev/null || echo 0)"
[[ "$started_epoch" -eq 0 ]] && continue
now_epoch="$(date -u +%s)"
age_hours=$(((now_epoch - started_epoch) / 3600))
[[ "$age_hours" -lt "$JOB_CONTAINER_MAX_AGE_HOURS" ]] && continue
job_candidates=$((job_candidates + 1))
size="$(docker ps -a --filter "id=${id}" --size --format '{{.Size}}' 2>/dev/null \
| awk '{print $1}' || true)"
if [[ "$DRY_RUN" == "1" ]]; then
log "job-контейнеры: [dry-run] снял бы '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?})"
continue
fi
if docker rm -f "$id" >/dev/null 2>&1; then
job_removed=$((job_removed + 1))
log "job-контейнеры: снят '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?} освобождён)"
else
log "job-контейнеры: НЕ удалось снять '${name}' (id ${id:0:12})"
fi
done
if [[ "$job_candidates" -eq 0 ]]; then
log "job-контейнеры: ${#job_ids[@]} шт., ни один не старше порога ${JOB_CONTAINER_MAX_AGE_HOURS}ч"
elif [[ "$DRY_RUN" == "1" ]]; then
log "job-контейнеры: к снятию ${job_candidates} из ${#job_ids[@]}"
else
log "job-контейнеры: снято ${job_removed} из ${job_candidates} кандидатов (порог ${JOB_CONTAINER_MAX_AGE_HOURS}ч)"
fi
fi
after_pct="$(disk_used_pct)"
log "готово: диск занят ${after_pct}% (было ${before_pct}%)"
# Сигнал в лог, если места всё равно мало — повод посмотреть глазами.
if [[ "$after_pct" -ge 85 ]]; then
log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно"
fi

View file

@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta
Throttle: при >10 401 events за 60s однократный digest event
(чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются.
before_send=_drop_basic_auth_noise (glitchtip-noise фикс): все события отсюда
дропаются перед отправкой в GlitchTip 401 от неаутентифицированного запроса
не ошибка сервиса, это боты сканируют закрытый basic_auth'ом сайт. Раньше это
был крупнейший источник шума в трекере (3 738 issue). Скрипт по-прежнему тэйлит
лог и печатает `[forwarder] 401 event sent: ...` в stdout (docker logs) просто
больше не шлёт эти события в issue-трекер. Смотри `_drop_basic_auth_noise` docstring.
Реальный Caddy JSON access log (v2) структура:
{
"level": "info",
@ -73,6 +80,41 @@ _shutdown = False
_last_exc_sent: float = 0.0
_EXC_THROTTLE_S: float = 300.0
# event_type-теги, которыми emit_event/emit_digest помечают КАЖДОЕ отправляемое
# событие (см. scope.set_tag("event_type", ...) ниже) — используются как ключ
# для before_send-фильтра.
_BASIC_AUTH_EVENT_TYPES = frozenset({"basic_auth_failed", "basic_auth_storm"})
def _drop_basic_auth_noise(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""before_send-фильтр: 401 неаутентифицированного basic_auth-запроса — НЕ
ошибка сервиса, а expected-поведение сканеров-ботов, ломящихся в закрытый
basic_auth'ом gendsgn.ru (`GET /wp-admin/install.php` и подобное). До этого
фикса emit_event/emit_digest слали КАЖДЫЙ такой 401 individual-событием (или
storm-digest) в GlitchTip remote_ip в message/тегах раздувал кардинальность
(3 738 issue, 2 019 различных заголовков, топ 222 события на «GET
/wp-admin/install.p»), топя содержательные алерты (OperationalError, sweep
failures) в шуме сканеров.
Дропаем НА ИСТОЧНИКЕ (before_send), не постфактум-чисткой issue-трекера
так шум не появляется вообще, а не изредка удаляется руками. Фильтруем по
тегу `event_type`, который ставят ТОЛЬКО emit_event/emit_digest необработанные
исключения самого форвардера (`capture_exception` в конце `main()`, реальный
баг скрипта) этот тег не несут и проходят фильтр как есть (см. `except
Exception` ниже в `main()`).
"""
tags = event.get("tags")
event_type = None
if isinstance(tags, dict):
event_type = tags.get("event_type")
elif isinstance(tags, list):
# sentry_sdk в некоторых версиях сериализует tags как list[tuple[str, str]]
# вместо dict — на всякий случай поддерживаем обе формы.
event_type = dict(tags).get("event_type") if tags else None
if event_type in _BASIC_AUTH_EVENT_TYPES:
return None
return event
def _signal_handler(signum: int, frame: object) -> None:
global _shutdown
@ -221,6 +263,7 @@ def main() -> None:
traces_sample_rate=0.0,
attach_stacktrace=False,
send_default_pii=False,
before_send=_drop_basic_auth_noise,
# Отключаем интеграции которые не нужны тонкому sidecar
default_integrations=False,
)

View file

@ -0,0 +1,77 @@
"""Тесты для `_drop_basic_auth_noise` (before_send-фильтр, glitchtip-noise).
Раньше форвардер слал КАЖДЫЙ basic_auth 401 (сканеры-боты, ломящиеся в закрытый
basic_auth'ом gendsgn.ru) individual-событием в GlitchTip — 3 738 issue, 2 019
различных заголовков (remote_ip раздувал кардинальность), топя содержательный
сигнал. `_drop_basic_auth_noise` дропает эти события НА ИСТОЧНИКЕ (before_send),
но НЕ должен трогать unhandled-ошибки самого форвардера (реальный баг скрипта
`capture_exception` без `event_type`-тега, аналог "500 должен пройти").
"""
from __future__ import annotations
import os
# DSN обязателен на module-level (`os.environ["GLITCHTIP_DSN"]`, fail-fast) — задаём
# ДО импорта forwarder.py, иначе импорт падает KeyError.
os.environ.setdefault("GLITCHTIP_DSN", "http://test@localhost/1")
from forwarder import _BASIC_AUTH_EVENT_TYPES, _drop_basic_auth_noise
def test_drops_individual_basic_auth_401() -> None:
"""emit_event() тегирует event_type=basic_auth_failed — 401 от бота-сканера,
не ошибка сервиса, должен быть отброшен (return None)."""
event = {
"tags": {"event_type": "basic_auth_failed", "remote_ip": "95.165.147.218"},
"message": "basic_auth 401 — GET /wp-admin/install.php from 95.165.147.218",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_basic_auth_storm_digest() -> None:
"""emit_digest() тегирует event_type=basic_auth_storm — тоже 401-класс, тоже
не ошибка сервиса, дропаем."""
event = {
"tags": {"event_type": "basic_auth_storm"},
"message": "basic_auth storm — 15 failed attempts in 60s",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_when_tags_serialized_as_list_of_tuples() -> None:
"""Некоторые версии sentry_sdk сериализуют tags как list[tuple[str, str]]
вместо dict фильтр обязан поддерживать обе формы."""
event = {"tags": [("event_type", "basic_auth_failed")]}
assert _drop_basic_auth_noise(event, {}) is None
def test_passes_through_forwarder_own_crash() -> None:
"""500-аналог: unhandled exception самого форвардера (capture_exception в
конце main(), реальный баг скрипта напр. PermissionError на STATE_FILE) не
несёт event_type-тег должен пройти НЕТРОНУТЫМ, не быть молча проглоченным
вместе с ботовым шумом."""
event = {
"level": "error",
"exception": {"values": [{"type": "PermissionError", "value": "denied"}]},
}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_event_without_tags() -> None:
event: dict = {"message": "something unrelated"}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_unrelated_tag_value() -> None:
event = {"tags": {"event_type": "something_else"}}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_basic_auth_event_types_are_exactly_the_two_emitters_use() -> None:
"""Явная фиксация словаря — emit_event → basic_auth_failed,
emit_digest basic_auth_storm (см. forwarder.py)."""
assert _BASIC_AUTH_EVENT_TYPES == frozenset({"basic_auth_failed", "basic_auth_storm"})

View file

@ -0,0 +1,36 @@
# systemd-journald drop-in — cap persistent journal disk usage on prod VPS.
#
# ЗАМЕР 2026-08-15 (ssh gendesign, read-only): `/var/log` занимал 3.1G. Наивная
# первая проверка `journalctl --disk-usage` показала только 174M и навела на
# ложный след «основной объём — не journald». На деле `journalctl --disk-usage`,
# запущенный НЕ из группы systemd-journal/adm, недосчитывает — он не может
# полноценно перечислить архивные *.journal файлы без прав на чтение. Прямой
# `du -sh /var/log/journal` дал 2.5G — это ~80% всего `/var/log`, ровно 100
# файлов по ~48M в /var/log/journal/<machine-id>/. Второй по размеру вклад —
# традиционный rsyslog (syslog/syslog.1/auth.log/kern.log/ufw.log/dmesg/btmp,
# ~0.6G) — те уже ротируются через logrotate (видны .1/.4.gz копии), отдельного
# вмешательства не требуют и вне scope этого файла.
#
# В /etc/systemd/journald.conf на проде НЕТ SystemMaxUse (все ключи закомменчены
# дефолтами) — без явного лимита journald довольствуется default-правилом
# «до 10% файловой системы», на VPS с диском ~145G это фактически безлимит.
#
# УСТАНОВКА НА СЕРВЕРЕ (руками, deploy.yml этот файл НЕ подхватывает —
# systemd-конфиги вне /opt/gendesign, деплой синкает только сам репозиторий):
# sudo mkdir -p /etc/systemd/journald.conf.d
# sudo cp /opt/gendesign/ops/journald-gendesign.conf.example \
# /etc/systemd/journald.conf.d/gendesign-max-use.conf
# sudo systemctl restart systemd-journald
#
# `restart systemd-journald` применяет лимит немедленно — journald сам
# провакуумит существующие архивные файлы вниз до SystemMaxUse (ожидаемый
# эффект: /var/log/journal схлопнется примерно с 2.5G до ~500M). Это НЕ
# `docker volume rm` / `caddy reload` — под общий deploy-guard не подпадает,
# но всё равно на живом проде: делает user сам после ревью PR.
#
# Значение 500M — консервативный запас на 4 vCPU/4-16G VPS с активным CI
# (docker/forgejo-runner логи в journald тоже льются). При необходимости
# больше retention для дебага — поднять SystemMaxUse, не удалять файл.
[Journal]
SystemMaxUse=500M

0
ops/restore.sh Normal file → Executable file
View file

View file

@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752).
ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала
на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за
чужой аналитической psql-сессией; вторая попытка деплоя ещё 16 минут. Опасность
не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за
ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих
data/sql то есть следующий блокирующий DDL повторил бы это.
`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный
CREATE INDEX он не оборвёт, а очередь не соберёт. Срабатывание = красный деплой
(ON_ERROR_STOP=on) вместо тихой очереди перед приложением.
ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант
«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4:
`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW
lock_timeout` 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX
CONCURRENTLY` тот ждёт завершения параллельных транзакций через VirtualXactLock,
и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная
сессия просто держала открытую транзакцию (ACCESS SHARE с CIC вообще не
конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение
изготавливало бы ровно ту аварию, от которой заведена проверка невалидных
индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят
противоположной политики, поэтому granularity файл, а не раннер.
ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца
сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL`
молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента.
ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы
задним числом не переписываются. Гейт смотрит только вперёд.
Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# каталог миграций -> минимальный NN, с которого правило обязательно.
# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая).
SQL_DIRS: dict[str, int] = {
"data/sql": 189,
"tradein-mvp/backend/data/sql": 250,
}
# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE,
# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать
# в очередь и увести за собой запросы приложения.
BLOCKING = re.compile(
r"\b(?:"
r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|"
r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|"
r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|"
r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL"
r")\b",
re.IGNORECASE,
)
# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку).
# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE
# по-прежнему обязан прикрыть свой ALTER.
CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE)
BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE)
SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE)
SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE)
NN_PREFIX = re.compile(r"^(\d+)")
def strip_noise(sql: str) -> str:
"""Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на
пробелы (сохраняя длину и переводы строк номера строк не съезжают).
Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...'
легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу.
Тела $$...$$ (DO-блоки) НЕ затираются там живёт исполняемый DDL.
"""
out: list[str] = []
i, n = 0, len(sql)
while i < n:
ch = sql[i]
nxt = sql[i + 1] if i + 1 < n else ""
if ch == "-" and nxt == "-":
while i < n and sql[i] != "\n":
out.append(" ")
i += 1
elif ch == "/" and nxt == "*":
depth = 1 # в PostgreSQL блочные комментарии вложенные
out.append(" ")
i += 2
while i < n and depth:
if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*":
depth += 1
out.append(" ")
i += 2
elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/":
depth -= 1
out.append(" ")
i += 2
else:
out.append("\n" if sql[i] == "\n" else " ")
i += 1
elif ch == "'":
out.append("'")
i += 1
while i < n:
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
out.append(" ")
i += 2
continue
if sql[i] == "'":
break
out.append("\n" if sql[i] == "\n" else " ")
i += 1
if i < n:
out.append("'")
i += 1
else:
out.append(ch)
i += 1
return "".join(out)
def scan(sql: str) -> list[str]:
"""-> список претензий к файлу; пустой список = файл в порядке."""
clean = strip_noise(sql)
statements = clean.split(";")
first_blocking: int | None = None
blocking_text = ""
for idx, stmt in enumerate(statements):
if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt):
first_blocking = idx
blocking_text = " ".join(stmt.split())[:80]
break
if first_blocking is None:
return []
set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None)
if set_local is None:
if any(SET_BARE_LT.search(s) for s in statements):
return [
f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). "
"Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY "
"в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN."
]
return [
f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой "
"строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет "
"в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)."
]
problems: list[str] = []
if not any(BEGIN_STMT.search(s) for s in statements[:set_local]):
problems.append(
"`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). "
"Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)."
)
if set_local > first_blocking:
problems.append(
f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — "
"к моменту DDL он ещё не действует. Подними его сразу под BEGIN."
)
return problems
def selftest() -> None:
ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
assert scan(ok) == [], scan(ok)
# красное: ровно случай 250 до фикса
bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0]
assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n")
assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n")
assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n")
assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n")
# красное: правильная строка, но в местах, где она не действует
assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0]
late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n"
assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late))
bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
assert "без LOCAL" in scan(bare)[0]
# зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC)
assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == []
assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == []
assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == []
# ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL
mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле"
mixed_ok = (
"CREATE INDEX CONCURRENTLY i ON t (c);\n"
"BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
)
assert scan(mixed_ok) == [], scan(mixed_ok)
# зелёное: DDL, которого нет — он в комментарии или в строковом литерале
assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == []
assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == []
assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == []
assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == []
assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == []
# зелёное: не-DDL миграции (backfill/seed) правила не касаются
assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == []
assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == []
print("selftest OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
failed = False
checked = 0
for dirname, min_nn in SQL_DIRS.items():
sql_dir = Path(dirname)
if not sql_dir.is_dir():
print(f"::error::{sql_dir} не найден — запускать из корня репозитория")
return 1
for path in sorted(sql_dir.glob("*.sql")):
m = NN_PREFIX.match(path.name)
if not m or int(m.group(1)) < min_nn:
continue # применено на проде до внедрения гейта — не переписываем
checked += 1
for problem in scan(path.read_text(encoding="utf-8")):
failed = True
print(f"::error file={path}::{problem}")
if failed:
return 1
print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757).
ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host`
(проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}`
`host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём
`services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо
уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из
job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля.
Список занятых портов не догадки, а замер `ss -ltnp` на bot-server 2026-08-06.
Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ
СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они
живут в bridge-сетях) сервис-контейнер с их портами ни с чем не столкнётся,
и запрещать их значило бы ронять сборку на ровном месте.
Правильный способ дать сборке БД как в ci-tradein.yml: явный `docker run`
в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера.
Запуск: python3 scripts/check-workflow-ports.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06)
RESERVED: dict[int, str] = {
22: "sshd хоста",
53: "systemd-resolved (127.0.0.53)",
80: "gendesign-caddy-1 (0.0.0.0:80)",
443: "gendesign-caddy-1 (0.0.0.0:443)",
2222: "forgejo, git-over-ssh (0.0.0.0:2222)",
3000: "gendesign-frontend-1 (127.0.0.1:3000)",
5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)",
8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)",
}
WORKFLOW_DIR = Path(".forgejo/workflows")
# "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port)
PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?")
PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)")
def scan(text: str) -> list[tuple[int, str, int]]:
"""-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p."""
hits: list[tuple[int, str, int]] = []
in_ports = False
ports_indent = 0
for lineno, raw in enumerate(text.splitlines(), 1):
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue # комментарий ничего не запускает (в них живут анти-примеры)
indent = len(raw) - len(raw.lstrip())
if in_ports and (not stripped.startswith("- ") or indent <= ports_indent):
in_ports = False
candidates: list[str] = []
if stripped.startswith("ports:"):
rest = stripped[len("ports:") :].strip()
if rest:
candidates.append(rest) # инлайн-форма: ports: ["5432:5432"]
else:
in_ports, ports_indent = True, indent
elif in_ports:
candidates.append(stripped)
candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish
for candidate in candidates:
for host_port, _container_port in PORT_PAIR.findall(candidate):
if int(host_port) in RESERVED:
hits.append((lineno, stripped, int(host_port)))
return hits
def selftest() -> None:
assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)]
assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000
assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000
assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443
# безопасные формы и посторонние двоеточия — не должны ронять сборку
assert scan(" ports:\n - 5432\n") == []
assert scan(" ports:\n - 15432:5432\n") == []
assert scan(" image: postgis/postgis:16-3.4\n") == []
assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == []
assert scan(" # ports:\n # - 5432:5432\n") == []
assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == []
# выход из блока ports: по отступу
assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == []
print("selftest OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
if not WORKFLOW_DIR.is_dir():
print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория")
return 1
failed = False
for path in sorted(WORKFLOW_DIR.glob("*.yml")):
for lineno, line, port in scan(path.read_text(encoding="utf-8")):
failed = True
print(
f"::error file={path},line={lineno}::публикация порта {port} — он занят "
f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер "
f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай "
f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP "
f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}"
)
if failed:
return 1
print("✓ ни один workflow не публикует занятый на VPS порт")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
#
# Проверяет инварианты периметра (см. корневой Caddyfile):
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
# напечатаны внутри самих юридических документов и уходят эквайеру.
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
# 3. trade-in API (/me, /history, /admin/*) отдаёт 401 анониму — данные B2B
# закрыты. Именно API, а не страница: см. комментарий у проверки ниже.
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder).
# 5. merahome.ru и meraotsenka.ru отдают 301 на канонический meraocenka.ru.
#
# ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record meraocenka.ru → IP VPS
# уже существовал И деплой прошёл (сертификат Let's Encrypt выпущен). Пока
# записи нет — они ожидаемо падают (DNS resolution failure / TLS handshake
# failure), это НЕ регресс периметра gendsgn.ru. Проверки 3 и 4 не зависят от
# DNS нового домена и обязаны быть зелёными всегда.
#
# Запуск вручную:
# bash scripts/smoke-mera-perimeter.sh
# Запуск в CI: .forgejo/workflows/perimeter-smoke.yml (workflow_dispatch + daily cron).
set -uo pipefail
BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}"
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
fail=0
check() {
local desc="$1" url="$2" expected="$3"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
fi
}
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
# эти три URL напечатаны внутри самих документов и уходят в заявку
# эквайеру — если rewrite выпадет из Caddyfile, оферта будет ссылаться на
# 404, и заявку завернут. Проверяем все три поимённо, потому что и в
# Caddyfile они перечислены поимённо (allowlist, не шаблон).
check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200
check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200
check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
# 2b. Те же B2B-пути в basePath-форме — 404. Это регресс-тест именно на
# matcher `handle /trade-in/mera-public/*`: расширь его случайно до
# `/trade-in/*` — и B2B-дерево уедет наружу через публичный домен, а
# проверка 2 (/v2 без префикса) этого НЕ заметит.
check "meraocenka.ru/trade-in/v2 — B2B path must 404" "$BASE_MERA/trade-in/v2" 404
check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)" "$BASE_MERA/trade-in/api/v1/me" 404
# 2c. Статика проксируется ТОЛЬКО из _next/static/*. Оптимизатор картинок
# /_next/image на лэндинге не нужен (next/image там не импортируется) и
# наружу не открыт — иначе аноним получил бы CPU-нагрузку по запросу.
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
#
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
# После #2555/#2558 trade-in ушёл с Caddy basic_auth на собственный логин:
# страница рендерится анониму, а RouteGuard уже на клиенте уводит на /login.
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
# открытом наружу бэкенде.
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
# на уровне Caddy до бэкенда).
check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/users" 401
# 5. Домены-спутники ведут на канонический (301, без следования редиректу —
# curl без -L, поэтому ждём именно код редиректа, а не 200 конечной страницы).
# Как и проверки 1-2, требуют DNS + выпущенного сертификата.
check "merahome.ru — 301 to canonical" "https://merahome.ru/" 301
check "meraotsenka.ru — 301 to canonical" "https://meraotsenka.ru/" 301
# 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
# СТОРОН прямо сейчас:
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
# платёжные пути и требует X-Authenticated-User → 401 анониму.
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404
check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404
check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
echo "========================================"
if [ "$fail" -eq 0 ]; then
echo "ALL CHECKS PASSED"
else
echo "SOME CHECKS FAILED — see FAIL lines above"
fi
exit "$fail"

View file

@ -6,12 +6,6 @@ DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein
CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"]
ENVIRONMENT=dev
# Yandex Geocoder API key (25k req/day free tier).
# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py).
# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ
# и упадут с SystemExit без него.
YANDEX_GEOCODER_API_KEY=
# DaData /clean/address — обогащение target адреса в estimate flow (PR Q1).
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
# Demo tier: 100 req/день — хватит для тестов и low-traffic prod.

View file

@ -15,7 +15,10 @@ frontend/node_modules/
frontend/.next/
frontend/out/
frontend/dist/
frontend/package-lock.json
# frontend/package-lock.json — TRACKED с #2770: Dockerfile и CI ставят `npm ci`,
# которому лок обязателен. Правишь frontend/package.json — регенерируй лок
# (`npm install --legacy-peer-deps --no-audit --no-fund`) в том же PR, иначе
# `npm ci` роняет сборку образа и весь deploy откатывается.
frontend/next-env.d.ts
# Docker

46
tradein-mvp/CHANGELOG.md Normal file
View file

@ -0,0 +1,46 @@
# История версий «МЕРА»
Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и
[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно
`## <semver> — <YYYY-MM-DD>` (машинно читается страницей истории версий).
## 2.1.0 — 2026-08-10
Первая версия с явным версионированием. Номер продолжает ряд, который до этого
показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние
отчёты.
### Добавлено
- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и
реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень
уверенности в оценке.
- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав
аналогов и сделок, формирование выкупной стоимости.
- Аналитика по дому — история размещений объявлений и продаж в доме.
- История прошлых оценок в личном кабинете, автодополнение адреса при поиске.
- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история).
- Чат поддержки на сайте, в том числе без входа в личный кабинет.
- Публичный лендинг «МЕРА».
- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта
страница истории версий.
### Изменено
- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более
раннего технического макета.
### Исправлено
- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе
комнатности не было варианта «Студия», из-за чего для студии подбирались
однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась.
- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор
автоматически расширяется (студии, срок объявлений, новостройки, радиус),
а над результатом показывается предупреждение о сниженной точности и о том,
какие параметры пришлось расширить.
- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части
адресов улица не распознавалась, и разделы оставались пустыми.
- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов.
- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
на обложке, некорректные допущения о сроке экспозиции.

View file

@ -57,8 +57,7 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
shell-скриптом deploy через `source .env.runtime` перед `compose up`.
2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
попадают `YANDEX_GEOCODER_API_KEY`, `COOKIE_ENCRYPTION_KEY`
всё, что нужно scripts/backfill_house_coords.py и application code внутри
попадают `COOKIE_ENCRYPTION_KEY` и остальные application-секреты внутри
контейнера.
```bash
@ -66,7 +65,6 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
@ -77,10 +75,9 @@ COOKIE_ENCRYPTION_KEY=<64-char hex>
```bash
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*).
# читаются ВНУТРИ container'а (app/*, scripts/*.py).
# Может быть симлинком на ../.env.runtime если переменные совпадают:
# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
YANDEX_GEOCODER_API_KEY=<key или пусто>
COOKIE_ENCRYPTION_KEY=<64-char hex>
GENDESIGN_FDW_PASSWORD=<password или пусто>
GLITCHTIP_DSN=<dsn или пусто>
@ -200,7 +197,6 @@ cat > tradein-mvp/.env.runtime <<EOF
TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_API_KEY=
EOF
chmod 600 tradein-mvp/.env.runtime

Some files were not shown because too many files have changed in this diff Show more