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
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:
commit
ddcd526284
581 changed files with 93616 additions and 13624 deletions
|
|
@ -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 → НЕ триггерит деплой
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
|
|
@ -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 "✓ деплой прошёл успешно"
|
||||
|
|
|
|||
38
.forgejo/workflows/perimeter-smoke.yml
Normal file
38
.forgejo/workflows/perimeter-smoke.yml
Normal 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
|
||||
91
.github/workflows/ci.yml
vendored
91
.github/workflows/ci.yml
vendored
|
|
@ -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
254
Caddyfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; контейнер держался вручную.)*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
backend/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
.coverage
|
||||
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
|
||||
coverage.xml
|
||||
|
|
|
|||
|
|
@ -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
269
backend/app/core/auth_db.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
256
backend/app/services/auth_session.py
Normal file
256
backend/app/services/auth_session.py
Normal 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)
|
||||
|
|
@ -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 (иначе сеть
|
||||
|
|
|
|||
|
|
@ -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"))],
|
||||
|
|
|
|||
|
|
@ -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"))],
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
|
||||
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
|
||||
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
|
||||
покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
|
||||
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
|
||||
покрытие domrf↔objective 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) — high≥3 / medium≥2 / 1 → low
|
||||
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример).
|
||||
• domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics
|
||||
docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён.
|
||||
• domrf_coverage — имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой
|
||||
из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга
|
||||
domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08:
|
||||
медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже.
|
||||
• history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий
|
||||
ряд → связь rate↔sales / тренды не установлены.
|
||||
• 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-пример «покрытие domrf↔objective 2.5%»). None → low.
|
||||
#2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие
|
||||
маппинга domrf↔objective, но такого продьюсера НЕТ и не было: слот
|
||||
`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% покрытия domrf↔objective», как было написано здесь
|
||||
раньше, — другая величина другого порядка.
|
||||
|
||||
Ключ фактора НЕ переименован намеренно: его читает фронт
|
||||
(`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: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||
domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1]. Имя ключа
|
||||
историческое — про маппинг domrf↔objective, продьюсера для которого
|
||||
нет и не было (#2464-H, см. _coverage_factor).
|
||||
history_months: глубина ряда (мес).
|
||||
confounded: True, если окно ряда пересекает шок-период (PR2).
|
||||
advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда).
|
||||
|
|
|
|||
|
|
@ -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 здесь НЕ выводим — нет
|
||||
дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и
|
||||
величина у неё другая: не «покрытие маппинга domrf↔objective ~2.5%», как
|
||||
было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective —
|
||||
замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%.
|
||||
|
||||
Порядок веток оставлен: если продьюсер появится, приоритет у него.
|
||||
"""
|
||||
if supply_layers is not None:
|
||||
coverage = supply_layers.get("domrf_coverage")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
146
backend/tests/_sentry_wiring_probe.py
Normal file
146
backend/tests/_sentry_wiring_probe.py
Normal 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]))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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), НЕ сырой.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "комфорт"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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% покрытия) —
|
||||
|
|
|
|||
|
|
@ -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> <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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 (слой жив, просто пуст)."""
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
98
backend/tests/skip_allowlist.txt
Normal file
98
backend/tests/skip_allowlist.txt
Normal 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
|
||||
182
backend/tests/sql/test_auth_sql_migrations.py
Normal file
182
backend/tests/sql/test_auth_sql_migrations.py
Normal 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."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
497
backend/tests/test_auth_db.py
Normal file
497
backend/tests/test_auth_db.py
Normal 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"]
|
||||
861
backend/tests/test_auth_session_guard.py
Normal file
861
backend/tests/test_auth_session_guard.py
Normal 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
|
||||
305
backend/tests/test_auth_session_service.py
Normal file
305
backend/tests/test_auth_session_service.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
backend/tests/test_skip_guard_loud.py
Normal file
44
backend/tests/test_skip_guard_loud.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
22
data/sql/188_regrant_quarter_price_index_fdw.sql
Normal file
22
data/sql/188_regrant_quarter_price_index_fdw.sql
Normal 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;
|
||||
123
data/sql/auth/001_identity_schema.sql
Normal file
123
data/sql/auth/001_identity_schema.sql
Normal 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;
|
||||
82
data/sql/auth/002_auth_app_role.sql
Normal file
82
data/sql/auth/002_auth_app_role.sql
Normal 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;
|
||||
111
data/sql/auth/003_users_seed.sql
Normal file
111
data/sql/auth/003_users_seed.sql
Normal 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;
|
||||
408
data/sql/auth/004_users_roles_and_access_state.sql
Normal file
408
data/sql/auth/004_users_roles_and_access_state.sql
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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-аутентификации.
|
||||
|
|
|
|||
|
|
@ -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). */}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 — хуки вернутся из истории (мертвее они там не станут).
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* и иметь один безопасный канал кодирования кадастрового номера.
|
||||
*
|
||||
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
|
||||
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
|
||||
* NspdOpportunityBlock, ParcelDrawer.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
67
ops/db-bootstrap/create_auth_db.sql
Normal file
67
ops/db-bootstrap/create_auth_db.sql
Normal 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 живёт внутри этой же БД).';
|
||||
50
ops/db-bootstrap/set_auth_app_password.sql
Normal file
50
ops/db-bootstrap/set_auth_app_password.sql
Normal 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
180
ops/docker-prune.sh
Executable 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
77
ops/glitchtip-auth-forwarder/test_forwarder.py
Normal file
77
ops/glitchtip-auth-forwarder/test_forwarder.py
Normal 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"})
|
||||
36
ops/journald-gendesign.conf.example
Normal file
36
ops/journald-gendesign.conf.example
Normal 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
0
ops/restore.sh
Normal file → Executable file
242
scripts/check-migration-lock-timeout.py
Normal file
242
scripts/check-migration-lock-timeout.py
Normal 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())
|
||||
125
scripts/check-workflow-ports.py
Normal file
125
scripts/check-workflow-ports.py
Normal 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())
|
||||
132
scripts/smoke-mera-perimeter.sh
Normal file
132
scripts/smoke-mera-perimeter.sh
Normal 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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
5
tradein-mvp/.gitignore
vendored
5
tradein-mvp/.gitignore
vendored
|
|
@ -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
46
tradein-mvp/CHANGELOG.md
Normal 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 страницах без пустых листов.
|
||||
- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
|
||||
на обложке, некорректные допущения о сроке экспозиции.
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue