Merge remote-tracking branch 'forgejo/main' into feat/mera-public-api
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Successful in 46s
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 / frontend-checks (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Successful in 46s
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 / frontend-checks (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
# Conflicts: # tradein-mvp/backend/app/core/rbac.py
This commit is contained in:
commit
b1fb7bb055
35 changed files with 1860 additions and 559 deletions
|
|
@ -41,9 +41,21 @@ In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`,
|
|||
|
||||
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
|
||||
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
|
||||
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2,
|
||||
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
|
||||
не доверяй `tail`.
|
||||
деструктивный DDL хитит прод на деплое.
|
||||
|
||||
**Номер новой миграции сверяй с `origin/main`, не с локальным `ls`** — локальное дерево не видит
|
||||
миграций, смерженных после ветвления (так разъехались 212 в #2682 и 234 в #2754):
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
|
||||
```
|
||||
|
||||
`-r` обязателен — без него `ls-tree` печатает сам каталог одной строкой, а не файлы.
|
||||
|
||||
Правило целиком — в докстринге `tradein-mvp/backend/tests/test_migration_numbering.py` (единственная
|
||||
формулировка контракта, #2683); он же гейтит его в CI. Дописывать имя в какой-либо список НЕ надо:
|
||||
`_manifest_applied.txt` удалён — он отставал и по построению не мог покраснеть.
|
||||
|
||||
## Rapid-merge trap
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,21 @@ jobs:
|
|||
CI_PG: ci-pg-tradein-${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# ПОЛНАЯ история, а не дефолтный depth=1 (#2683).
|
||||
# tests/test_migration_numbering.py сверяет номер новой миграции с
|
||||
# origin/main и с точкой ветвления. Ровно этот флаг их и даёт: при
|
||||
# depth=0 checkout идёт refspec'ом `+refs/heads/*:refs/remotes/origin/*`
|
||||
# (видно в логе прогона), при depth=1 — только `+<sha>:refs/remotes/
|
||||
# pull/N/head`, то есть ни ветки main, ни общего предка в клоне нет.
|
||||
# Дотянуть main отдельным `git fetch` НЕЛЬЗЯ: из job-контейнера
|
||||
# git.gendsgn.ru:443 недостижим (проверено, run 6977 — connection
|
||||
# refused), сеть есть только у самого checkout.
|
||||
#
|
||||
# Гейт при отсутствии эталона краснеет, а не пропускается: молча
|
||||
# пропущенная проверка и есть тот зелёный, который ничего не проверяет.
|
||||
# Пак репозитория ~33 MiB — полный fetch дешевле разбора коллизии на проде.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Поднять Postgres и собрать схему tradein
|
||||
working-directory: .
|
||||
|
|
@ -192,13 +207,22 @@ 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'ов — сьют гоняется целиком (#2722).
|
||||
#
|
||||
|
|
|
|||
|
|
@ -176,6 +176,11 @@ jobs:
|
|||
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует
|
||||
# origin/main и общего предка с HEAD, а даёт их именно depth=0 — при
|
||||
# depth=1 checkout тянет один sha и ветки main в клоне нет.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
# Официальный standalone-инсталлер: системный `pip install uv` на
|
||||
|
|
@ -679,8 +684,9 @@ jobs:
|
|||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||
# #2216) glob'ом не подхватывается.
|
||||
# ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало
|
||||
# до main, здесь и применяется, а имя закрепляется в _schema_migrations.
|
||||
# На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683).
|
||||
|
||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ on:
|
|||
# деплоя ниже — без этого триггера правка 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 месяцами
|
||||
|
|
|
|||
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
|
||||
22
Caddyfile
22
Caddyfile
|
|
@ -222,8 +222,7 @@ www.gendsgn.ru {
|
|||
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
|
||||
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
|
||||
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
|
||||
# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что
|
||||
# уже описан для status.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).
|
||||
|
|
@ -423,25 +422,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; контейнер держался вручную.)*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""Инварианты миграций БД `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, поэтому здесь проверяются инварианты, которые
|
||||
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
|
||||
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
|
||||
Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был
|
||||
(tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога
|
||||
и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git:
|
||||
tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты,
|
||||
выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят
|
||||
регрессии, которые иначе всплывают только на проде во время деплоя.
|
||||
|
||||
Тест не требует БД — только чтение файлов.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,6 +78,16 @@ services:
|
|||
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}
|
||||
|
|
@ -331,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
|
||||
|
|
@ -365,9 +385,27 @@ 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]
|
||||
# GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15,
|
||||
# см. tradein-mvp/backend/app/api/v1/glitchtip.py): вебхуки шлёт РЕАЛЬНО
|
||||
# этот celery-воркер (apps/alerts/webhooks.py send_webhook — не glitchtip-web),
|
||||
# получателю `webhook` нужен доступ к http://tradein-backend:8000/... —
|
||||
# tradein-backend сидит на gendesign_shared, у glitchtip-* её раньше не было
|
||||
# вообще (та же грабля, что #2709 у redis: сеть должна быть общей ДО того,
|
||||
# как переменная окружения с URL вообще имеет смысл). default — обязательно
|
||||
# явно, иначе воркер потеряет Postgres/Redis-брокер (см. комментарий у redis
|
||||
# выше про неявную привязку к default).
|
||||
networks: [default, shared]
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
|
|
|
|||
|
|
@ -18,7 +18,15 @@
|
|||
# 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'ы):
|
||||
|
|
@ -35,6 +43,9 @@ 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')" "$*"; }
|
||||
|
||||
|
|
@ -93,6 +104,73 @@ else
|
|||
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}%)"
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
@ -154,6 +154,27 @@ check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/use
|
|||
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"
|
||||
|
|
|
|||
219
tradein-mvp/backend/app/api/v1/glitchtip.py
Normal file
219
tradein-mvp/backend/app/api/v1/glitchtip.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""GlitchTip → Telegram алерты (мониторинг сейчас нем: `alerts_projectalert`/
|
||||
`alerts_alertrecipient` пусты, `EMAIL_URL=consolemail://` печатает письма в
|
||||
stdout и никуда их не доставляет — аудит на проде 2026-08-15).
|
||||
|
||||
GlitchTip (self-hosted, `errors.gendsgn.ru`, образ `glitchtip/glitchtip:6.1.6`)
|
||||
умеет слать получателю типа `webhook` (``RecipientType.GENERAL_WEBHOOK`` —
|
||||
"General Slack-compatible webhook"). И issue-алерты (``apps/alerts/webhooks.py
|
||||
send_issue_as_webhook``), и uptime-алерты (``apps/uptime/webhooks.py
|
||||
_send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ низкоуровневую
|
||||
``send_webhook()`` — ``aiohttp.ClientSession.post(url, json=asdict(WebhookPayload
|
||||
(text=..., attachments=[...])))``, БЕЗ каких-либо заголовков (ни Authorization,
|
||||
ни подписи, ни X-*). Значит:
|
||||
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
|
||||
``{"text": str, "attachments": [{"title","title_link","text","color",
|
||||
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
|
||||
2) единственный канал для аутентификации — сам URL (как и Slack-вебхуки).
|
||||
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
|
||||
нечем (GlitchTip-сторона его не добавляет).
|
||||
|
||||
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
|
||||
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
|
||||
``secrets.compare_digest`` constant-time compare, тот же env. Отличие от
|
||||
rbac-паттерна: ТАМ пустой секрет — fail-open (есть второй рубеж, roles.yaml).
|
||||
ЗДЕСЬ секрет — единственный рубеж вообще, поэтому пустой секрет ИЛИ
|
||||
несконфигурированный Telegram-бот → 503 "не настроено", а не тихий
|
||||
fail-open настежь.
|
||||
|
||||
Путь ФИКСИРОВАННЫЙ (не несёт секрет в себе) — так его можно добавить в
|
||||
``app.core.rbac._PUBLIC_PATHS`` одной строкой (точное совпадение, без
|
||||
regex/prefix-веток в ``rbac_guard``). Сам путь — не секрет, секрет — только
|
||||
значение query-параметра.
|
||||
|
||||
Сетевая связность (docker-compose.prod.yml, корневой стек): вебхуки шлёт
|
||||
``glitchtip-worker`` (celery-таска), НЕ ``glitchtip-web`` — оба сейчас сидят
|
||||
только в ``gendesign_default``. tradein-backend слушает на ``gendesign_shared``
|
||||
(алиас неявный — Docker embedded DNS резолвит по ``container_name``, тот же
|
||||
приём уже используется Caddy → ``tradein-backend:8000``, см. Caddyfile).
|
||||
Значит ``glitchtip-worker`` тоже должен быть подписан на ``gendesign_shared``,
|
||||
иначе имя ``tradein-backend`` не резолвится — общей сети нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Telegram sendMessage лимит — 4096 символов (см. support.py MAX_MESSAGE_LENGTH
|
||||
# для исходящих сообщений пользователя; здесь лимит на ИСХОДЯЩЕЕ в Telegram, тот
|
||||
# же потолок). Суффикс обрезки учтён в _truncate.
|
||||
_TELEGRAM_MAX_LEN = 4096
|
||||
_TRUNCATE_SUFFIX = "\n… (обрезано)"
|
||||
|
||||
# Узкий интерактивный бюджет (тот же принцип, что #tgsupport-web review H1 в
|
||||
# support.py): GlitchTip-таска ждёт HTTP-ответ синхронно (её собственный aiohttp
|
||||
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
||||
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
||||
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||
|
||||
|
||||
class GlitchTipAttachment(BaseModel):
|
||||
"""Slack-совместимый attachment. Issue- и uptime-алерты заполняют РАЗНЫЕ
|
||||
подмножества полей (uptime не шлёт ``fields``/``color``) — все опциональны,
|
||||
``extra="allow"`` на случай будущих версий GlitchTip."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
title: str | None = None
|
||||
title_link: str | None = None
|
||||
text: str | None = None
|
||||
color: str | None = None
|
||||
fields: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class GlitchTipWebhookPayload(BaseModel):
|
||||
"""Тело POST от GlitchTip ``send_webhook()`` — одинаковое для issue- и
|
||||
uptime-алертов (см. docstring модуля)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
text: str | None = None
|
||||
attachments: list[GlitchTipAttachment] | None = None
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = _TELEGRAM_MAX_LEN) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - len(_TRUNCATE_SUFFIX)] + _TRUNCATE_SUFFIX
|
||||
|
||||
|
||||
def _field_value(attachment: GlitchTipAttachment, label: str) -> str | None:
|
||||
"""Ищет значение поля attachment.fields по title (issue-алерты кладут туда
|
||||
"Project" литералом — см. apps/alerts/webhooks.py send_issue_as_webhook)."""
|
||||
for field in attachment.fields or []:
|
||||
if str(field.get("title", "")).strip().lower() == label.lower():
|
||||
value = field.get("value")
|
||||
return str(value) if value is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def _format_known_payload(payload: GlitchTipWebhookPayload, received_at: datetime) -> str:
|
||||
lines = [f"GlitchTip: {payload.text or 'Alert'}"]
|
||||
for attachment in payload.attachments or []:
|
||||
block: list[str] = []
|
||||
project = _field_value(attachment, "Project")
|
||||
if project:
|
||||
block.append(f"Проект: {project}")
|
||||
if attachment.title:
|
||||
block.append(attachment.title)
|
||||
if attachment.text:
|
||||
block.append(attachment.text)
|
||||
if attachment.title_link:
|
||||
block.append(f"Ссылка: {attachment.title_link}")
|
||||
if block:
|
||||
lines.append("")
|
||||
lines.extend(block)
|
||||
lines.append("")
|
||||
lines.append(f"Получено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_unknown_payload(raw_body: bytes, received_at: datetime) -> str:
|
||||
"""Payload не распознан ни как issue-, ни как uptime-алерт (нет ни `text`,
|
||||
ни `attachments`, либо тело — не JSON-объект вовсе) — не роняем запрос,
|
||||
пересылаем как есть с пометкой (см. требование задачи: неизвестная форма
|
||||
payload не должна давать 500)."""
|
||||
text_repr = raw_body.decode("utf-8", errors="replace")
|
||||
header = "GlitchTip webhook: неизвестный формат payload, пересылаю как есть"
|
||||
return _truncate(
|
||||
f"{header}\n\n{text_repr}\n\nПолучено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"
|
||||
)
|
||||
|
||||
|
||||
def _build_message(raw_body: bytes, received_at: datetime) -> str:
|
||||
try:
|
||||
data = json.loads(raw_body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
try:
|
||||
payload = GlitchTipWebhookPayload.model_validate(data)
|
||||
except ValidationError:
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
if payload.text is None and not payload.attachments:
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
return _truncate(_format_known_payload(payload, received_at))
|
||||
|
||||
|
||||
def _alerts_configured() -> bool:
|
||||
"""Все три части ОБЯЗАНЫ быть заданы: секрет (auth), токен бота, chat_id
|
||||
темы алертов. Отсутствие любой — 503, а не тихий no-op и не fail-open."""
|
||||
return bool(
|
||||
settings.tradein_internal_auth_secret
|
||||
and settings.telegram_bot_token
|
||||
and settings.telegram_alerts_chat_id
|
||||
)
|
||||
|
||||
|
||||
def _verify_secret(provided: str) -> None:
|
||||
expected = settings.tradein_internal_auth_secret
|
||||
if not secrets.compare_digest(provided or "", expected):
|
||||
logger.warning("glitchtip webhook: invalid or missing secret query param")
|
||||
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
||||
|
||||
|
||||
@router.post("/ops/glitchtip-webhook")
|
||||
async def glitchtip_webhook(
|
||||
request: Request,
|
||||
secret: Annotated[str, Query()] = "",
|
||||
) -> dict[str, str]:
|
||||
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
||||
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
||||
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
||||
|
||||
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
||||
хендлер сам делает единственную проверку (``secret`` query-параметр).
|
||||
"""
|
||||
if not _alerts_configured():
|
||||
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
|
||||
|
||||
_verify_secret(secret)
|
||||
|
||||
raw_body = await request.body()
|
||||
received_at = datetime.now(UTC)
|
||||
text = _build_message(raw_body, received_at)
|
||||
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_alerts_chat_id,
|
||||
text=text,
|
||||
message_thread_id=settings.telegram_alerts_topic_id or None,
|
||||
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
|
||||
# может легально висеть воркерные минуты ретраев.
|
||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
)
|
||||
except TelegramApiError:
|
||||
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
|
||||
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
|
||||
|
||||
return {"status": "ok"}
|
||||
|
|
@ -997,6 +997,15 @@ class Settings(BaseSettings):
|
|||
# message_thread_id топика внутри support-группы, в который идут зеркала.
|
||||
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
|
||||
|
||||
# ── GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15) ──
|
||||
# Отдельная тема от TELEGRAM_SUPPORT_TOPIC_ID выше — алерты об ошибках прода
|
||||
# НЕ должны литься в топик, куда пишут живые клиенты. См. app/api/v1/glitchtip.py.
|
||||
# Пусто/0 = вебхук отвечает 503 "not configured" (fail-closed, не fail-open —
|
||||
# это единственный auth-рубеж эндпоинта, в отличие от rbac-путей).
|
||||
# ENV: TELEGRAM_ALERTS_CHAT_ID, TELEGRAM_ALERTS_TOPIC_ID.
|
||||
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
|
||||
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
|
||||
|
||||
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
||||
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
||||
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,18 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||
|
||||
from app.core.config import settings
|
||||
|
||||
# Платёжная нотификация Т-Банка (PR-D2, готовит почву под PR-D3 — путь ещё
|
||||
# закрыт rbac до того момента). Сервер-к-серверу, без сессии/X-Authenticated-User
|
||||
# → в общем лимитере попал бы в один и тот же per-IP ключ с любым другим
|
||||
# анонимным трафиком с той же исходящей сети банка. Мотив НЕ «банк упрётся в
|
||||
# лимит» — 300/60с и так щедро — а «429 никогда не должен стать причиной, по
|
||||
# которой денежное состояние разъехалось»: для банка недоставленная нотификация
|
||||
# = «доставка не удалась», альтернативного канала нет, а очередь ретраев
|
||||
# растягивается на сутки. Только точный путь notify — НЕ checkout (тот
|
||||
# инициирует пользователь с сессией/курсором в браузере, абуз там штатно
|
||||
# лимитируем как любой другой API-путь).
|
||||
_PAYMENTS_NOTIFY_PATH = "/api/v1/trade-in/payments/notify"
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Sliding-window rate limit на /api/v1/*. Health и статика — без лимита."""
|
||||
|
|
@ -42,6 +54,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||
|
||||
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
|
||||
path = request.url.path
|
||||
# Платёжная нотификация — мимо ОБЩЕГО (per-user/per-IP shared) лимитера,
|
||||
# но НЕ без лимита вовсе: idiom `_notify_limiter` (`SlidingWindowLimiter`,
|
||||
# тот же приём, что `support.py:92`/`:319` — узкий per-feature бюджет
|
||||
# ВМЕСТО общего, не полное отключение защиты). Порог заведомо выше любого
|
||||
# штатного трафика банка (документированное расписание ретраев неизвестно,
|
||||
# см. mera-tbank-acquiring-recon.md — берём с кратным запасом), но конечен:
|
||||
# полное отключение оставило бы путь без backstop против шторма запросов —
|
||||
# подпись отсекает мусор ПОСЛЕ разбора тела (PR-D3), не до.
|
||||
if path == _PAYMENTS_NOTIFY_PATH:
|
||||
retry_after = _notify_limiter.check(_client_ip(request))
|
||||
if retry_after is not None:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Слишком много запросов. Попробуйте позже."},
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
return await call_next(request)
|
||||
# Лимитируем только API; health и прочее — пропускаем.
|
||||
if not path.startswith("/api/"):
|
||||
return await call_next(request)
|
||||
|
|
@ -143,6 +172,16 @@ class SlidingWindowLimiter:
|
|||
return None
|
||||
|
||||
|
||||
# Щедрый бюджет для платёжной нотификации (PR-D2): 3000/60с (50 req/s) — на два
|
||||
# порядка выше любого правдоподобного трафика банка (тест 400/60с проходит с
|
||||
# запасом в 7.5×), но конечен — backstop против шторма запросов на путь, где
|
||||
# подпись проверяется уже ПОСЛЕ разбора тела. Ключ — client IP (у сервер-к-
|
||||
# серверу вызова нет сессии/X-Authenticated-User).
|
||||
_NOTIFY_RATE_LIMIT = 3000
|
||||
_NOTIFY_RATE_WINDOW_S = 60.0
|
||||
_notify_limiter = SlidingWindowLimiter(limit=_NOTIFY_RATE_LIMIT, window_s=_NOTIFY_RATE_WINDOW_S)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,12 @@ _PUBLIC_PATHS = frozenset(
|
|||
# не секрет, читает только process env — быстрая справка для клиента/
|
||||
# поддержки/смоук-теста, не должна требовать сессию.
|
||||
"/api/v1/trade-in/version",
|
||||
# GlitchTip webhook → Telegram (app/api/v1/glitchtip.py): вызывается
|
||||
# ИЗ glitchtip-worker (docker-сеть gendesign_shared), не может нести
|
||||
# X-Authenticated-User/сессию. Путь фиксированный и не секрет — секрет
|
||||
# это query-параметр `secret`, который проверяет сам хендлер
|
||||
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
|
||||
"/api/v1/trade-in/ops/glitchtip-webhook",
|
||||
# Публичный B2C-периметр МЕРЫ (meraocenka.ru): у посетителя лендинга
|
||||
# идентичности нет и не будет — Caddy на этом домене вообще без
|
||||
# basic_auth. Обе ручки только читают (SELECT/прокси автокомплита) и не
|
||||
|
|
|
|||
|
|
@ -40,7 +40,29 @@ logger = logging.getLogger(__name__)
|
|||
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
|
||||
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
|
||||
# циклическую зависимость.
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
#
|
||||
# PR-D2: `/api/v1/trade-in/payments/notify` — заранее в skip-набор (defense-in-
|
||||
# depth), хотя rbac ещё закрывает этот путь до PR-D3. Причины две:
|
||||
# 1) сам путь не должен попадать в аудит вообще — тело нотификации содержит
|
||||
# `Token`/`Pan`/`ExpDate` (см. `app/main.py._before_send`, тот же мотив, что
|
||||
# и вырезание тела из мониторинга); хоть это middleware само по себе тело
|
||||
# запроса в payload не пишет (только status_code/path/method), путь не
|
||||
# должен зависеть от того, что кто-то потом добавит поле "body" в событие;
|
||||
# 2) НЕ авторизующая проверка: `RequestAuditMiddleware` внешний относительно
|
||||
# `rbac_guard` и читает сырой `X-Authenticated-User` (см. `main.py` порядок
|
||||
# middleware) — анонимный POST на notify с подделанным заголовком
|
||||
# `X-Authenticated-User: admin` иначе писал бы фальшивые события в
|
||||
# `user_events` с атрибуцией admin, при этом rbac при этом ничего не знает
|
||||
# (сам гейт отдельно, 401 всё равно вернёт до PR-D3).
|
||||
_PUBLIC_PATHS = frozenset(
|
||||
{
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/api/v1/trade-in/payments/notify",
|
||||
}
|
||||
)
|
||||
|
||||
# Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в
|
||||
# аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from app.api.v1 import (
|
|||
brand,
|
||||
buildings,
|
||||
geocode,
|
||||
glitchtip,
|
||||
lead,
|
||||
me,
|
||||
privacy_admin,
|
||||
|
|
@ -69,20 +70,32 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
|||
if settings.glitchtip_dsn:
|
||||
from app.observability.sentry_scrub import (
|
||||
redact_telegram_bot_token,
|
||||
scrub_payment_request_body,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) +
|
||||
RetryError fingerprint-стабилизация (glitchtip-noise) — см.
|
||||
app/tgbot_main.py._before_send (та же композиция без последнего шага,
|
||||
тот бот geocoder не зовёт). PII/token — тот же риск: теперь этот процесс
|
||||
тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||
include_local_variables=False ниже — первый рубеж защиты. RetryError —
|
||||
этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse),
|
||||
которые ретраят Nominatim через tenacity; см.
|
||||
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
|
||||
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
|
||||
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
|
||||
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
|
||||
TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||
include_local_variables=False ниже — первый рубеж защиты.
|
||||
|
||||
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные —
|
||||
режет `request.data` целиком только для `/payments/*`, остальные пути
|
||||
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
|
||||
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
|
||||
(before_send и before_send_transaction) — вчерашний баг в Птице закрыл
|
||||
только error-канал, transaction-канал остался вообще без обработчика.
|
||||
|
||||
RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/*
|
||||
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
||||
sentry_scrub.stabilize_retry_error_fingerprint."""
|
||||
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
|
||||
|
|
@ -100,6 +113,10 @@ if settings.glitchtip_dsn:
|
|||
# держит base URL с токеном в локальных переменных стек-фрейма — default
|
||||
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
|
||||
before_send=_before_send,
|
||||
# PR-D2: тот же обработчик на transaction-канал — traces_sample_rate=0.0
|
||||
# сегодня не шлёт трейсы вообще, но это belt-and-suspenders на случай,
|
||||
# если трейсинг когда-нибудь включат (см. docstring _before_send выше).
|
||||
before_send_transaction=_before_send,
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
FastApiIntegration(),
|
||||
|
|
@ -252,6 +269,7 @@ app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]
|
|||
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
|
||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
|
||||
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
|
||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||
|
|
|
|||
|
|
@ -29,7 +29,36 @@ from tenacity import RetryError
|
|||
|
||||
_REDACTED = "[REDACTED]"
|
||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
||||
_PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"})
|
||||
# PR-D2 (payments perimeter hardening): + платёжные поля Т-Банка (customer_email/
|
||||
# customer_phone из checkout, pan/expdate/cardid/rebillid/token/terminalkey из
|
||||
# notify) — belt-and-suspenders поверх `scrub_payment_request_body` ниже, которая
|
||||
# вырезает `request.data` для /payments/* целиком: этот словарь всё равно нужен
|
||||
# для extra/contexts И на случай, если платёжное поле когда-нибудь попадёт в
|
||||
# error event НЕ через request.data (напр. кто-то положит его в extra вручную).
|
||||
_PII_KEYS = frozenset(
|
||||
{
|
||||
"client_name",
|
||||
"client_phone",
|
||||
"client_email",
|
||||
"phone",
|
||||
"email",
|
||||
"name",
|
||||
"customer_email",
|
||||
"customer_phone",
|
||||
"pan",
|
||||
"expdate",
|
||||
"cardid",
|
||||
"rebillid",
|
||||
"token",
|
||||
"terminalkey",
|
||||
}
|
||||
)
|
||||
|
||||
# Сегмент пути платёжного периметра (notify + checkout + любой будущий
|
||||
# /payments/* суб-путь) — PR-D2, готовит почву под PR-D3 (эндпоинты ещё не
|
||||
# существуют). Матчим по сегменту, не по конкретному эндпоинту, чтобы не
|
||||
# требовать правки этого файла на каждый новый платёжный путь.
|
||||
_PAYMENTS_URL_SEGMENT = "/api/v1/trade-in/payments/"
|
||||
|
||||
# Telegram Bot API токен в пути URL: /bot<numeric_id>:<secret-part>/<method>.
|
||||
# Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query
|
||||
|
|
@ -179,6 +208,39 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
|||
return event
|
||||
|
||||
|
||||
def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Вырезать `event['request']['data']` целиком для платёжных путей (PR-D2).
|
||||
|
||||
Ключ-based `scrub_pii_event` НЕ спасает платёжную нотификацию: sentry_sdk
|
||||
2.64 (`integrations/starlette.py`) кладёт ПОЛНОЕ тело запроса в
|
||||
`event.request.data`, и `send_default_pii=False` этот путь не гейтит — тот
|
||||
флаг управляет только куками, не телом запроса (проверено живьём на соседнем
|
||||
продукте). Тело нотификации Т-Банка несёт `Token`/`Pan`/`ExpDate`/`CardId`/
|
||||
`RebillId`/`DATA` — банк сам выбирает имена полей, перечислить их все заранее
|
||||
нельзя, поэтому единственная безопасная стратегия для этого пути — не
|
||||
отправлять тело целиком, а не пытаться вычистить отдельные ключи.
|
||||
|
||||
Матчим по сегменту `/api/v1/trade-in/payments/` (не по конкретному
|
||||
эндпоинту) — покрывает notify, checkout и любой будущий суб-путь одним
|
||||
фильтром, без правки этого файла на каждое расширение платёжного API.
|
||||
Сравнение регистронезависимое: `_PUBLIC_PATHS` (rbac) — точное множество без
|
||||
учёта регистра только у Caddy, не у Python, так что нестандартный регистр
|
||||
пути технически может долететь до обработчика и породить событие.
|
||||
|
||||
Композировать с `scrub_pii_event`/`redact_telegram_bot_token`, а не вместо
|
||||
них — этот шаг закрывает только `request.data`, extra/contexts и
|
||||
traceback-locals остаются на ответственности остальных шагов композиции.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
request = event.get("request")
|
||||
if isinstance(request, dict):
|
||||
url = request.get("url")
|
||||
if isinstance(url, str) and _PAYMENTS_URL_SEGMENT in url.lower():
|
||||
request.pop("data", None)
|
||||
return event
|
||||
|
||||
|
||||
def _redact_strings(obj: Any) -> Any:
|
||||
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
|
||||
строке (не только по конкретным ключам) — токен может оказаться в locals
|
||||
|
|
|
|||
|
|
@ -45,20 +45,31 @@ if settings.glitchtip_dsn:
|
|||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
|
||||
from app.observability.sentry_scrub import (
|
||||
scrub_payment_request_body,
|
||||
scrub_pii_event,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
||||
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
|
||||
"""PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise).
|
||||
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
|
||||
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
|
||||
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда — belt-and-
|
||||
suspenders на случай, если платёжные данные когда-нибудь попадут в
|
||||
`request`/`extra`. Тот же обработчик на оба канала ниже — см.
|
||||
app/main.py._before_send (идентичный мотив, не дублировать без причины).
|
||||
|
||||
Этот процесс гоняет `geocode_missing_listings` (ночной batch, сотни
|
||||
адресов за прогон) — @retry-декорированные Nominatim-хелперы
|
||||
(app/services/geocoder.py) на исчерпанных ретраях исторически плодили
|
||||
по отдельному GlitchTip issue на КАЖДЫЙ адрес (RetryError.__str__()
|
||||
тащит нестабильный repr() Future). См. sentry_scrub docstring.
|
||||
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
|
||||
следом за платёжным body-wipe: этот процесс гоняет
|
||||
`geocode_missing_listings` (ночной batch, сотни адресов за прогон) —
|
||||
@retry-декорированные Nominatim-хелперы (app/services/geocoder.py) на
|
||||
исчерпанных ретраях исторически плодили по отдельному GlitchTip issue
|
||||
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
|
||||
См. sentry_scrub docstring.
|
||||
"""
|
||||
scrubbed = scrub_pii_event(event, hint)
|
||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return stabilize_retry_error_fingerprint(scrubbed, hint)
|
||||
|
|
@ -70,6 +81,7 @@ if settings.glitchtip_dsn:
|
|||
traces_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send=_before_send,
|
||||
before_send_transaction=_before_send,
|
||||
integrations=[
|
||||
SqlalchemyIntegration(),
|
||||
HttpxIntegration(),
|
||||
|
|
|
|||
|
|
@ -58,12 +58,17 @@ if settings.glitchtip_dsn:
|
|||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
|
||||
from app.observability.sentry_scrub import redact_telegram_bot_token, scrub_pii_event
|
||||
from app.observability.sentry_scrub import (
|
||||
redact_telegram_bot_token,
|
||||
scrub_payment_request_body,
|
||||
scrub_pii_event,
|
||||
)
|
||||
|
||||
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
|
||||
"""Композиция PII-scrub (form-данные) + Telegram bot-токен redaction
|
||||
(#tgsupport review). Токен утекает ДВУМЯ независимыми векторами, которые
|
||||
`include_local_variables=False` ниже и этот хук закрывают вместе:
|
||||
"""Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) +
|
||||
Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ
|
||||
независимыми векторами, которые `include_local_variables=False` ниже и
|
||||
этот хук закрывают вместе:
|
||||
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
||||
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
||||
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
||||
|
|
@ -72,8 +77,16 @@ if settings.glitchtip_dsn:
|
|||
перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор
|
||||
— belt-and-suspenders на случай #1 (если include_local_variables
|
||||
случайно вернут) И на span data.
|
||||
|
||||
Платёжный body-wipe — belt-and-suspenders: этот процесс не держит ASGI-
|
||||
приложения (нет `request` в event сегодня), но тот же обработчик передан
|
||||
ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия
|
||||
со всеми точками инициализации sentry_sdk в проекте (см. app/main.py).
|
||||
"""
|
||||
scrubbed = scrub_pii_event(event, hint)
|
||||
scrubbed = scrub_payment_request_body(event, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint)
|
||||
|
|
@ -86,6 +99,7 @@ if settings.glitchtip_dsn:
|
|||
send_default_pii=False,
|
||||
include_local_variables=False,
|
||||
before_send=_before_send,
|
||||
before_send_transaction=_before_send,
|
||||
integrations=[
|
||||
HttpxIntegration(),
|
||||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||
|
|
|
|||
|
|
@ -1,257 +0,0 @@
|
|||
# _manifest_applied.txt — CONTRACT (issue #2216)
|
||||
#
|
||||
# Отсортированный список ВСЕХ имён миграций (bare filename) в data/sql/,
|
||||
# которые на момент коммита уже применены/забейслайнены на проде.
|
||||
# Прод трекает миграции по bare-filename в public._schema_migrations —
|
||||
# переименование или удаление применённого файла => повторный прогон на
|
||||
# проде (новый filename считается неприменённым) => дубль-эффекты/ошибки.
|
||||
#
|
||||
# ПРАВИЛА (enforced tests/test_migrations_manifest.py):
|
||||
# 1. Каждое имя здесь ОБЯЗАНО существовать в data/sql/ (нельзя rename/rm applied).
|
||||
# 2. Новый .sql-файл => НЕ переиспользуй NN-префикс (кроме 6 grandfathered дублей).
|
||||
# 3. Добавляя новую миграцию — допиши её имя сюда В ТОМ ЖЕ PR (список отсортирован).
|
||||
#
|
||||
# Комментарии (# ...) и пустые строки тест игнорирует.
|
||||
001_trade_in_estimates.sql
|
||||
002_core_tables.sql
|
||||
003_seed_deals.sql
|
||||
004_extend_trade_in_estimates.sql
|
||||
005_geocode_tracking.sql
|
||||
007_estimate_photos.sql
|
||||
008_crm_fields.sql
|
||||
009_houses.sql
|
||||
010_houses_alter.sql
|
||||
011_listings_alter.sql
|
||||
012_sellers.sql
|
||||
013_listings_alter_seller.sql
|
||||
014_house_reviews.sql
|
||||
015_scrape_runs.sql
|
||||
016_listings_snapshots.sql
|
||||
017_house_placement_history.sql
|
||||
018_avito_imv_evaluations.sql
|
||||
019_listings_alter_cian.sql
|
||||
020_houses_alter_cian.sql
|
||||
021_management_companies.sql
|
||||
022_agents_table.sql
|
||||
023_offer_price_history.sql
|
||||
024_houses_price_dynamics.sql
|
||||
025_house_reliability_checks.sql
|
||||
026_external_valuations.sql
|
||||
027_cian_session_cookies.sql
|
||||
028_matching_tables.sql
|
||||
029_extend_matching_valuation_dynamics.sql
|
||||
030_avito_imv_cache_key_unique.sql
|
||||
031_houses_alter_yandex.sql
|
||||
032_yandex_history.sql
|
||||
033_listings_alter_yandex.sql
|
||||
034_trade_in_estimates_geom.sql
|
||||
035_drop_duplicate_indexes.sql
|
||||
040_houses_extend.sql
|
||||
041_house_sources_noop.sql
|
||||
042_listing_sources_price_divergence_idx.sql
|
||||
043_house_reviews_extend.sql
|
||||
044_external_valuations_link.sql
|
||||
045_house_placement_history_extend.sql
|
||||
046_views.sql
|
||||
047_cian_history_sanitize.sql
|
||||
050_search_optimization.sql
|
||||
051_scrape_runs_extend.sql
|
||||
052_scrape_schedules.sql
|
||||
053_scraper_settings.sql
|
||||
054_scraper_settings_global.sql
|
||||
060_postgres_fdw_extension.sql
|
||||
061_drop_legacy_cad_buildings.sql
|
||||
062_clean_avito_addresses.sql
|
||||
063_backfill_houses_and_link_listings.sql
|
||||
064_house_imv_phase_c.sql
|
||||
065_trade_in_estimates_floor_optional.sql
|
||||
066_address_mismatch_audit.sql
|
||||
067_v_street_sales_vs_listings.sql
|
||||
068_drop_v_street_sales_vs_listings.sql
|
||||
069_trade_in_estimates_dadata_fields.sql
|
||||
070_houses_dadata_enrichment.sql
|
||||
071_houses_cian_zhk_url.sql
|
||||
072_scrape_schedules_seed_cian_rosreestr.sql
|
||||
073_normalize_repair_state.sql
|
||||
075_backfill_repair_state_from_description.sql
|
||||
076_account_estimate_quota.sql
|
||||
077_dedup_hash_plain_key_backfill.sql
|
||||
078_scrape_schedules_seed_yandex_sweep.sql
|
||||
079_listing_source_history.sql
|
||||
080_asking_to_sold_ratios.sql
|
||||
081_trade_in_estimates_expected_sold.sql
|
||||
082_scrape_schedules_seed_ratio_refresh.sql
|
||||
083_trade_in_estimates_created_by.sql
|
||||
084_brand_praktika_fill.sql
|
||||
084_scrape_schedules_seed_n1_sweep.sql
|
||||
085_quarter_price_index_fdw.sql
|
||||
086_deals_address_trgm_index.sql
|
||||
087_fdw_server_options.sql
|
||||
088_scrape_schedules_seed_search_matview_refresh.sql
|
||||
089_listings_geo_precision.sql
|
||||
090_scrape_schedules_seed_deactivate_stale_avito.sql
|
||||
091_scrape_schedules_seed_yandex_address_backfill.sql
|
||||
092_sber_price_index.sql
|
||||
093_scrape_schedules_seed_sber_index_pull.sql
|
||||
094_cadastral_unify.sql
|
||||
095_dead_schema.sql
|
||||
096_scrape_schedules_seed_rosreestr_quarter_poll.sql
|
||||
097_index_hygiene.sql
|
||||
098_asking_to_sold_ratios_tiered.sql
|
||||
099_brand_praktika_logo_wordmark.sql
|
||||
100_enable_deactivate_stale_avito.sql
|
||||
101_gendesign_reader_role.sql
|
||||
102_grant_listings_gendesign_reader.sql
|
||||
103_scrape_schedules_seed_newbuilding_enrich.sql
|
||||
104_index_hygiene_geom_dedup.sql
|
||||
105_market_schema_yandex_enrichment.sql
|
||||
106_scrape_schedules_seed_yandex_newbuilding_sweep.sql
|
||||
107_scrape_schedules_seed_cian_city_sweep.sql
|
||||
108_clean_avito_addresses_v2.sql
|
||||
108_merge_duplicate_houses.sql
|
||||
109_asking_to_sold_ratio_segment_filter.sql
|
||||
110_scrape_schedules_seed_geocode_missing_listings.sql
|
||||
111_listings_avito_detail_fields.sql
|
||||
112_scrape_schedules_seed_avito_detail_backfill.sql
|
||||
113_deactivate_ghost_duplicate_listings.sql
|
||||
113_yandex_detail_backfill.sql
|
||||
114_disable_n1_sweep.sql
|
||||
115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql
|
||||
116_offer_price_history_change_trigger.sql
|
||||
117_listings_last_seen_deactivate_index.sql
|
||||
118_enable_cian_city_sweep.sql
|
||||
119_yandex_city_sweep_center_combos.sql
|
||||
120_restore_partial_active_indexes.sql
|
||||
121_remove_brand_praktika.sql
|
||||
121_yandex_rich_fields.sql
|
||||
122_enable_domclick_city_sweep.sql
|
||||
123_avito_newbuilding_sweep_schedule.sql
|
||||
124_cad_buildings_local.sql
|
||||
124_deglue_avito_addresses.sql
|
||||
125_scrape_schedules_seed_cadastral_geo_match.sql
|
||||
126_scrape_schedules_seed_cian_full_load.sql
|
||||
127_scrape_schedules_seed_avito_full_load.sql
|
||||
128_listings_card_hash.sql
|
||||
129_avito_full_load_incremental_split.sql
|
||||
130_backfill_listings_house_id_fk.sql
|
||||
130_ekb_geoportal_buildings.sql
|
||||
131_fix_diff_percent_overflow.sql
|
||||
132_scrape_schedules_seed_house_imv.sql
|
||||
133_listings_uq_source_source_id.sql
|
||||
134_listings_geom_geography_gist.sql
|
||||
135_scrape_schedules_seed_house_dedup_merge.sql
|
||||
136_backfill_listings_house_id_fk_source_identity.sql
|
||||
137_listings_addr_norm_trgm.sql
|
||||
138_domclick_bff_rewrite_schedule.sql
|
||||
139_premium_houses.sql
|
||||
140_yandex_house_type_backfill.sql
|
||||
141_cian_promote_house_type.sql
|
||||
142_premium_buildings_curated.sql
|
||||
143_building_sale_share_schema.sql
|
||||
144_gar_canon_addr_match.sql
|
||||
145_building_sale_share_plausible_denom.sql
|
||||
146_sale_share_45d_and_zhkh_denom.sql
|
||||
147_canon_strip_geo_prefixes.sql
|
||||
148_dedup_apartments_in_sale_share.sql
|
||||
149_zhkh_priority_denominator.sql
|
||||
150_sale_share_listing_geo_filter.sql
|
||||
151_clean_bare_street_aliases.sql
|
||||
152_sale_share_floors_guard.sql
|
||||
153_sale_share_listings_floors_plausibility.sql
|
||||
154_market_contract_views.sql
|
||||
155_reader_grants_to_contract_views.sql
|
||||
156_revoke_raw_from_reader.sql
|
||||
157_scrape_proxies.sql
|
||||
158_seed_proxy_healthcheck_schedule.sql
|
||||
159_houses_fias_idx.sql
|
||||
160_seed_deactivate_stale_domklik_n1.sql
|
||||
161_backfill_scraped_at_active_recent.sql
|
||||
162_seed_deals_freshness_monitor.sql
|
||||
163_disable_deactivate_stale_domklik.sql
|
||||
164_yandex_url_canonicalize_active_dups.sql
|
||||
165_remove_n1_source.sql
|
||||
166_purge_listings_phones.sql
|
||||
167_drop_client_pii.sql
|
||||
168_fdw_osm_poi_ekb.sql
|
||||
169_osm_poi_ekb_local.sql
|
||||
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
|
||||
171_scrape_schedules_seed_geoportal_coords_backfill.sql
|
||||
172_trade_in_leads.sql
|
||||
173_scrape_proxies_add_domclick_affinity.sql
|
||||
174_domclick_session_cookies.sql
|
||||
175_scrape_schedules_seed_domclick_detail_backfill.sql
|
||||
176_domrf_kapremont.sql
|
||||
177_deals_city_region.sql
|
||||
178_deal_city_price_bands.sql
|
||||
179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||
180_seed_sber_freshness_monitor.sql
|
||||
181_clamp_bad_listing_dates.sql
|
||||
182_trade_in_leads_consent_proof.sql
|
||||
183_reenable_deactivate_stale_domklik.sql
|
||||
184_user_events.sql
|
||||
185_account_quota_overrides.sql
|
||||
186_tg_support.sql
|
||||
187_web_support_chat.sql
|
||||
188_tg_support_chat_id_scope.sql
|
||||
189_account_estimate_usage_nonnegative.sql
|
||||
190_sale_share_price_bucket_signature.sql
|
||||
191_account_quota_unlimited_flag.sql
|
||||
192_tradein_users_auth.sql
|
||||
193_tradein_users_seed.sql
|
||||
194_deal_city_price_bands_tiers.sql
|
||||
195_scrape_schedules_seed_deal_city_price_bands_refresh.sql
|
||||
196_listings_city.sql
|
||||
197_backfill_listings_city_from_url.sql
|
||||
198_scrape_proxy_rotations.sql
|
||||
199_scrape_proxies_asocks_rotate_url.sql
|
||||
200_region_code_foreign_cities.sql
|
||||
201_purge_dead_mobileproxy_proxies.sql
|
||||
202_listing_source_snapshot_budget_sec.sql
|
||||
203_purge_geocode_cache_house_letter.sql
|
||||
204_cian_oblast_sweeps_secondary.sql
|
||||
205_sales_vs_listings_city_filter.sql
|
||||
206_scrape_schedules_cut_wasteful_load.sql
|
||||
207_backfill_yandex_cian_city_geo_cleanup.sql
|
||||
208_reenable_domclick_detail_backfill.sql
|
||||
209_scrape_proxies_disabled_reason.sql
|
||||
210_scrape_proxy_source_bans.sql
|
||||
211_sales_vs_listings_segment_guard.sql
|
||||
212_sber_index_pull_weekly.sql
|
||||
213_listings_snapshots_status_vocab.sql
|
||||
214_drop_dead_run_metrics.sql
|
||||
215_avito_full_load_window_matches_cadence.sql
|
||||
216_dead_code_sweep.sql
|
||||
#
|
||||
# 2026-08-06: список догнан до факта прода. Проверка перед правкой —
|
||||
# _schema_migrations на tradein-postgres: 209 применённых имён, здесь было
|
||||
# 178; расхождение — 31 имя, все в одну сторону (применено, но не заморожено).
|
||||
# Обратного расхождения нет: ни одной строки, которой не было бы на проде.
|
||||
#
|
||||
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
|
||||
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
|
||||
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
|
||||
#
|
||||
# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент
|
||||
# правки они уже слиты в main и применены на проде (см. _schema_migrations),
|
||||
# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш;
|
||||
# self-maintenance-контракт (см. докстринг test_migrations_manifest.py)
|
||||
# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для
|
||||
# 222/225 по прецеденту 233_payments.sql.
|
||||
222_db_audit_cleanup.sql
|
||||
225_listing_source_snapshots_run_id_idx.sql
|
||||
233_payments.sql
|
||||
234_scrape_runs_ban_kind_unknown.sql
|
||||
240_trade_in_estimates_retain_until.sql
|
||||
250_drop_duplicate_expires_at_index.sql
|
||||
251_listings_drop_ceiling_height.sql
|
||||
254_listings_backfill_avito_rating_glued_address.sql
|
||||
257_listings_backfill_yandex_source_url.sql
|
||||
258_houses_imv_transient_attempts.sql
|
||||
259_data_quality_drop_pct_cadastr.sql
|
||||
260_houses_drop_has_panorama.sql
|
||||
261_listings_search_mv_drop_placeholder_columns.sql
|
||||
262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
|
||||
263_scrape_schedules_wave2_cian_newbuilding_only_false.sql
|
||||
264_deactivate_stale_avito_cap_mult.sql
|
||||
265_deactivate_stale_yandex_cap_mult.sql
|
||||
266_seed_deactivate_stale_null_segment_yandex_cian.sql
|
||||
|
|
@ -60,6 +60,16 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy
|
|||
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
|
||||
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
|
||||
|
||||
# Гейт номеров миграций (#2683) сверяется с origin/main и точкой ветвления. Где
|
||||
# git-эталона нет — прогон внутри prod-образа, экспорт исходников без .git —
|
||||
# проверять не с чем, и тест это ГОВОРИТ вслух вместо тихого зелёного.
|
||||
# В CI пропуска не бывает: при CI/GITHUB_ACTIONS та же ветка делает pytest.fail
|
||||
# (отсутствие эталона в пайплайне — сломанный гейт, а не «нечего проверять»),
|
||||
# а ci-tradein.yml/deploy-tradein.yml берут checkout с fetch-depth: 0 — при нём
|
||||
# checkout сам приносит refs/remotes/origin/*, отдельный git fetch не нужен и
|
||||
# из job-контейнера всё равно не проходит (run 6977, connection refused).
|
||||
tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted
|
||||
tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
|
||||
# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
|
||||
# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
|
||||
# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ def test_corridor_clamp_above_corridor_tier_c_clamps() -> None:
|
|||
|
||||
def test_corridor_clamp_inside_corridor_is_noop() -> None:
|
||||
# Эконом/комфорт: headline в коридоре (с учётом slack) → ничего не меняется.
|
||||
new_ppm2, new_price, new_low, new_high, clamped = _clamp(
|
||||
new_ppm2, _, _, _, clamped = _clamp(
|
||||
median_ppm2=140_000, corridor_high=130_000, count=20, tier="C"
|
||||
)
|
||||
assert clamped is False
|
||||
|
|
|
|||
291
tradein-mvp/backend/tests/test_glitchtip_webhook.py
Normal file
291
tradein-mvp/backend/tests/test_glitchtip_webhook.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""Offline-тесты приёмника GlitchTip webhook-алертов
|
||||
(POST /api/v1/trade-in/ops/glitchtip-webhook) — app/api/v1/glitchtip.py.
|
||||
|
||||
Проверяет HTTP-контракт: успешная пересылка issue-/uptime-алертов в
|
||||
Telegram (клиент замокан), отказ без валидного секрета, отказ при
|
||||
несконфигурированных настройках, обрезка длинного текста под лимит Telegram
|
||||
(4096), graceful-обработка неизвестной формы payload (НЕ 500).
|
||||
|
||||
NEVER touches real DB / real Telegram API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import json
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import glitchtip as glitchtip_module
|
||||
from app.services.tgbot.client import TelegramApiError
|
||||
|
||||
_SECRET = "test-shared-secret"
|
||||
_ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""По умолчанию вебхук полностью сконфигурирован — отдельные тесты
|
||||
переопределяют конкретные поля."""
|
||||
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", _SECRET)
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "fake-token")
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", -1004443088679)
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_topic_id", 158)
|
||||
|
||||
|
||||
class _FakeTelegramClient:
|
||||
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети."""
|
||||
|
||||
calls: ClassVar[list[dict[str, Any]]] = []
|
||||
_response: ClassVar[dict[str, Any] | Exception] = {"message_id": 1}
|
||||
|
||||
def __init__(self, _token: str) -> None:
|
||||
pass
|
||||
|
||||
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
|
||||
_FakeTelegramClient.calls.append(kwargs)
|
||||
if isinstance(_FakeTelegramClient._response, Exception):
|
||||
raise _FakeTelegramClient._response
|
||||
return _FakeTelegramClient._response
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
_FakeTelegramClient.calls = []
|
||||
_FakeTelegramClient._response = {"message_id": 1}
|
||||
monkeypatch.setattr(glitchtip_module, "TelegramClient", _FakeTelegramClient)
|
||||
return _FakeTelegramClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(glitchtip_module.router, prefix="/api/v1/trade-in")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
_ISSUE_PAYLOAD = {
|
||||
"text": "GlitchTip Alert",
|
||||
"attachments": [
|
||||
{
|
||||
"title": "ValueError: something broke",
|
||||
"title_link": "https://errors.gendsgn.ru/organizations/gendesign/issues/123/",
|
||||
"text": "app/services/foo.py in bar",
|
||||
"color": "#e03131",
|
||||
"fields": [
|
||||
{"title": "Project", "value": "tradein-backend", "short": True},
|
||||
{"title": "Environment", "value": "production", "short": True},
|
||||
],
|
||||
"mrkdown_in": ["text"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
_UPTIME_PAYLOAD = {
|
||||
"text": "GlitchTip Uptime Alert",
|
||||
"attachments": [
|
||||
{
|
||||
"title": "gendsgn.ru",
|
||||
"title_link": "https://errors.gendsgn.ru/organizations/gendesign/uptime/1/",
|
||||
"text": "The monitored site has gone down.",
|
||||
"image_url": None,
|
||||
"color": None,
|
||||
"fields": None,
|
||||
"mrkdown_in": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── успешная пересылка ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_issue_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None:
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"status": "ok"}
|
||||
assert len(_fake_telegram_client.calls) == 1
|
||||
call = _fake_telegram_client.calls[0]
|
||||
assert call["chat_id"] == -1004443088679
|
||||
assert call["message_thread_id"] == 158
|
||||
assert "ValueError: something broke" in call["text"]
|
||||
assert "tradein-backend" in call["text"] # Project field
|
||||
assert "errors.gendsgn.ru" in call["text"]
|
||||
|
||||
|
||||
def test_uptime_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None:
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_UPTIME_PAYLOAD)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_fake_telegram_client.calls) == 1
|
||||
call = _fake_telegram_client.calls[0]
|
||||
assert "GlitchTip Uptime Alert" in call["text"]
|
||||
assert "gone down" in call["text"]
|
||||
assert call["message_thread_id"] == 158
|
||||
|
||||
|
||||
def test_telegram_failure_returns_502_not_500(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
_FakeTelegramClient._response = TelegramApiError("sendMessage", 400, "chat not found")
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 502
|
||||
assert r.status_code != 500
|
||||
|
||||
|
||||
# ── auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_missing_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
|
||||
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 401
|
||||
assert _fake_telegram_client.calls == []
|
||||
|
||||
|
||||
def test_wrong_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
|
||||
r = client.post(f"{_ENDPOINT}?secret=wrong-value", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 401
|
||||
assert _fake_telegram_client.calls == []
|
||||
|
||||
|
||||
def test_secret_not_configured_returns_503_not_500(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", "")
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 503
|
||||
assert r.status_code != 500
|
||||
assert _fake_telegram_client.calls == []
|
||||
|
||||
|
||||
def test_bot_not_configured_returns_503(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "")
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 503
|
||||
assert _fake_telegram_client.calls == []
|
||||
|
||||
|
||||
def test_alerts_chat_id_not_configured_returns_503(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", 0)
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 503
|
||||
assert _fake_telegram_client.calls == []
|
||||
|
||||
|
||||
# ── обрезка длинного текста ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_long_payload_truncated_to_telegram_limit(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
huge_payload = {
|
||||
"text": "GlitchTip Alert",
|
||||
"attachments": [
|
||||
{
|
||||
"title": "Huge issue",
|
||||
"title_link": "https://errors.gendsgn.ru/x",
|
||||
"text": "x" * 10000,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=huge_payload)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
sent_text = _fake_telegram_client.calls[0]["text"]
|
||||
assert len(sent_text) <= 4096
|
||||
assert sent_text.endswith("(обрезано)")
|
||||
|
||||
|
||||
def test_unknown_form_huge_raw_body_truncated(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
r = client.post(
|
||||
f"{_ENDPOINT}?secret={_SECRET}",
|
||||
content=("x" * 10000).encode(),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
sent_text = _fake_telegram_client.calls[0]["text"]
|
||||
assert len(sent_text) <= 4096
|
||||
|
||||
|
||||
# ── неизвестная форма payload — НЕ 500 ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_unknown_json_shape_forwarded_with_marker(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Ни `text`, ни `attachments` — форма, которую GlitchTip НЕ шлёт сегодня,
|
||||
но контракт задачи требует не падать 500, а переслать как есть."""
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json={"some_field": "some_value", "n": 42})
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
sent_text = _fake_telegram_client.calls[0]["text"]
|
||||
assert "неизвестный формат" in sent_text
|
||||
assert "some_value" in sent_text
|
||||
|
||||
|
||||
def test_non_json_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
|
||||
r = client.post(
|
||||
f"{_ENDPOINT}?secret={_SECRET}",
|
||||
content=b"not-json-at-all {{{",
|
||||
headers={"content-type": "text/plain"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
sent_text = _fake_telegram_client.calls[0]["text"]
|
||||
assert "неизвестный формат" in sent_text
|
||||
assert "not-json-at-all" in sent_text
|
||||
|
||||
|
||||
def test_json_array_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
|
||||
"""Валидный JSON, но не объект (top-level list) — тоже неизвестная форма."""
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=[1, 2, 3])
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_fake_telegram_client.calls) == 1
|
||||
|
||||
|
||||
def test_empty_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", content=b"")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_fake_telegram_client.calls) == 1
|
||||
|
||||
|
||||
# ── формат сообщения ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_message_format_json_roundtrip(client: TestClient, _fake_telegram_client: Any) -> None:
|
||||
"""Sanity: убеждаемся, что тестовый payload реально валиден как JSON (не
|
||||
полагаемся на literal dict без проверки сериализации)."""
|
||||
body = json.dumps(_ISSUE_PAYLOAD)
|
||||
r = client.post(
|
||||
f"{_ENDPOINT}?secret={_SECRET}",
|
||||
content=body.encode(),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"""listing_segment upsert self-heal: строка не должна вечно застревать с NULL-сегментом.
|
||||
|
||||
Баг: `scraper_kit.base.save_listings` писал `listing_segment` ТОЛЬКО в INSERT-ветке
|
||||
upsert'а — колонки не было ни в `ON CONFLICT (dedup_hash) DO UPDATE SET`, ни в
|
||||
reconcile-UPDATE (dedup_hash-drift fallback, срабатывает при UniqueViolation по
|
||||
(source, source_id)). Итог: если первый скрейп объявления не смог определить сегмент
|
||||
(классификатор вернул None), строка рождалась с `listing_segment IS NULL` и
|
||||
НИКОГДА не самочинялась на последующих пересборах, даже когда сегмент становился
|
||||
определим. Симптом лечили отдельной джобой деактивации
|
||||
(`deactivate_stale_{cian,yandex}_null_segment`, миграция 266, PR #2908) — чистит
|
||||
мусор в `is_active`, но не лечит саму запись сегмента.
|
||||
|
||||
Fix: `listing_segment = COALESCE(EXCLUDED.listing_segment, listings.listing_segment)`
|
||||
в ON CONFLICT DO UPDATE + `listing_segment = COALESCE(:listing_segment, listing_segment)`
|
||||
в reconcile UPDATE — тот же идиом, что уже применён для `city` (#2594,
|
||||
test_listings_city_from_sweep.py) и `kitchen_area_m2`/`ceiling_height_m` (#2007):
|
||||
новое значение обновляет строку, но НЕ затирает уже известное пустым.
|
||||
|
||||
Тесты здесь, как и соседний test_listings_city_from_sweep.py, мокают db.execute и
|
||||
проверяют SQL-текст + bind-параметры (unit-уровень, без реальной Postgres) —
|
||||
COALESCE-семантику "новое непустое побеждает / пустое не затирает старое" исполняет
|
||||
сама база при выполнении запроса.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.base import ScrapedLot as KitLot
|
||||
from scraper_kit.base import save_listings as kit_save_listings
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _nested_ctx() -> Any:
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _mock_db_insert_path(listing_id: int = 42) -> MagicMock:
|
||||
"""Session mock для fresh INSERT path (xmax = 0 → inserted)."""
|
||||
insert_row = MagicMock()
|
||||
insert_row.id = listing_id
|
||||
insert_row.inserted = True
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
s = str(sql)
|
||||
res = MagicMock()
|
||||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "FROM listings_snapshots" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "INSERT INTO listings (" in s:
|
||||
res.fetchone.return_value = insert_row
|
||||
else:
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
db.begin_nested.side_effect = _nested_ctx
|
||||
return db
|
||||
|
||||
|
||||
def _find_call(db: MagicMock, needle: str) -> tuple[str, dict[str, Any]]:
|
||||
for call in db.execute.call_args_list:
|
||||
sql = str(call.args[0])
|
||||
if needle in sql:
|
||||
params = call.args[1] if len(call.args) > 1 else {}
|
||||
return sql, params
|
||||
raise AssertionError(f"SQL containing {needle!r} not found")
|
||||
|
||||
|
||||
def _kit_matcher() -> MagicMock:
|
||||
matcher = MagicMock()
|
||||
matcher.match_or_create_house.return_value = (101, 1.0, "new")
|
||||
matcher.upsert_listing_source.return_value = None
|
||||
return matcher
|
||||
|
||||
|
||||
def _lot(
|
||||
source: str = "avito",
|
||||
source_id: str = "1",
|
||||
listing_segment: str | None = None,
|
||||
) -> KitLot:
|
||||
return KitLot(
|
||||
source=source,
|
||||
source_url=f"https://www.{source}.ru/item/{source_id}",
|
||||
source_id=source_id,
|
||||
address="ул. Победы, 30",
|
||||
listing_segment=listing_segment,
|
||||
price_rub=3_000_000,
|
||||
)
|
||||
|
||||
|
||||
# ── save_listings(...) — INSERT path ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_listings_writes_listing_segment_into_insert_sql() -> None:
|
||||
"""listing_segment передаётся в SQL params И колонка есть в INSERT-списке."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(listing_segment="vtorichka")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert "listing_segment" in sql, "listing_segment column must be in INSERT column list"
|
||||
assert params["listing_segment"] == "vtorichka"
|
||||
|
||||
|
||||
def test_save_listings_listing_segment_none_backward_compat() -> None:
|
||||
"""Классификатор не определил сегмент (None) — INSERT всё равно проходит, NULL."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(listing_segment=None)
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
_sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert params["listing_segment"] is None
|
||||
|
||||
|
||||
# ── ON CONFLICT DO UPDATE — COALESCE self-heal (главный фикс) ──────────────
|
||||
|
||||
|
||||
def test_save_listings_on_conflict_coalesces_listing_segment() -> None:
|
||||
"""ON CONFLICT DO UPDATE — listing_segment = COALESCE(EXCLUDED.listing_segment,
|
||||
listings.listing_segment), не blind overwrite и не "никогда не обновляется"."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(listing_segment="vtorichka")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert "listing_segment = COALESCE(" in sql
|
||||
assert "EXCLUDED.listing_segment, listings.listing_segment" in sql
|
||||
# Повторный скрейп с ОПРЕДЕЛЁННЫМ сегментом — новое значение уходит в EXCLUDED,
|
||||
# COALESCE на стороне Postgres применит его к прежде-NULL строке (self-heal).
|
||||
assert params["listing_segment"] == "vtorichka"
|
||||
|
||||
|
||||
def test_save_listings_on_conflict_listing_segment_none_does_not_blind_overwrite() -> None:
|
||||
"""Повторный скрейп БЕЗ сегмента (classifier снова None) — SQL всё равно
|
||||
несёт COALESCE (не голый EXCLUDED), значит уже известный сегмент строки
|
||||
в БД НЕ будет затёрт пустым при выполнении запроса."""
|
||||
db = _mock_db_insert_path()
|
||||
lot = _lot(listing_segment=None)
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
sql, params = _find_call(db, "INSERT INTO listings (")
|
||||
assert "listing_segment = COALESCE(" in sql
|
||||
assert "EXCLUDED.listing_segment, listings.listing_segment" in sql
|
||||
assert params["listing_segment"] is None
|
||||
|
||||
|
||||
# ── Reconcile UPDATE (dedup_hash drift) — тот же self-heal ─────────────────
|
||||
|
||||
|
||||
def test_save_listings_reconcile_update_coalesces_listing_segment() -> None:
|
||||
"""dedup_hash-drift reconcile UPDATE path — тоже COALESCE(:listing_segment,
|
||||
listing_segment), не blind overwrite. Без этого фикса строки, дошедшие до
|
||||
reconcile (content дрейфит, старый dedup_hash не находится, INSERT ловит
|
||||
UniqueViolation по (source, source_id)), остались бы незалеченными."""
|
||||
import psycopg.errors
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
uv_orig = psycopg.errors.UniqueViolation()
|
||||
integrity_err = IntegrityError("INSERT INTO listings ...", {}, uv_orig)
|
||||
rec_row = MagicMock()
|
||||
rec_row.id = 88
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||
s = str(sql)
|
||||
res = MagicMock()
|
||||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "FROM listings_snapshots" in s:
|
||||
res.fetchone.return_value = None
|
||||
elif "INSERT INTO listings (" in s:
|
||||
raise integrity_err
|
||||
elif "UPDATE listings" in s and "SET dedup_hash" in s:
|
||||
res.fetchone.return_value = rec_row
|
||||
else:
|
||||
res.fetchone.return_value = None
|
||||
return res
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
|
||||
@contextmanager
|
||||
def _nested() -> Any:
|
||||
try:
|
||||
yield MagicMock()
|
||||
except IntegrityError:
|
||||
raise
|
||||
|
||||
db.begin_nested.side_effect = _nested
|
||||
|
||||
lot = _lot(source="avito", source_id="7960764619", listing_segment="novostroyki")
|
||||
|
||||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||||
|
||||
sql, params = _find_call(db, "SET dedup_hash")
|
||||
assert "listing_segment = COALESCE(:listing_segment, listing_segment)" in sql
|
||||
assert params["listing_segment"] == "novostroyki"
|
||||
215
tradein-mvp/backend/tests/test_migration_numbering.py
Normal file
215
tradein-mvp/backend/tests/test_migration_numbering.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Инварианты нумерации миграций trade-in (issues #2216, #2683).
|
||||
|
||||
КОНТРАКТ. ОДНА ФОРМУЛИРОВКА, И ОНА ЗДЕСЬ — больше нигде её дублировать не надо.
|
||||
|
||||
Эталон «что уже закреплено на проде» — origin/main, а не файл-список.
|
||||
deploy-tradein.yml прогоняет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP
|
||||
(падение миграции => красный деплой) и трекает применённое по bare filename в
|
||||
public._schema_migrations. То есть «файл доехал до main» == «имя закреплено на
|
||||
проде», и вести это знание отдельно от git незачем: git и есть журнал.
|
||||
|
||||
Отсюда ровно два инварианта, и ниже проверяются именно они.
|
||||
|
||||
1. Имя, существовавшее в точке ветвления, нельзя переименовать или удалить.
|
||||
Прод помнит СТАРОЕ имя; новое считается неприменённым и прогоняется
|
||||
повторно — дубль-INSERT / повторный DDL / PK violation под ON_ERROR_STOP,
|
||||
то есть либо красный деплой, либо тихо задвоенные данные. Нужно изменить
|
||||
уже применённую миграцию — заводи НОВЫЙ файл, старый оставь как есть.
|
||||
|
||||
2. НОВЫЙ файл обязан нести NN-префикс, свободный не только в рабочем дереве,
|
||||
но и в origin/main. Порядок применения — `ls | sort`, два файла с одним NN
|
||||
дают неопределённый порядок. Шесть исторических дублей (084/108/113/121/
|
||||
124/130) не «новые» и не флагаются.
|
||||
|
||||
ДОПИСЫВАТЬ НИЧЕГО НЕ НАДО. Автор миграции кладёт файл со свободным номером — и
|
||||
всё. Списка, который можно забыть обновить, здесь больше нет: до #2683 таким
|
||||
списком был data/sql/_manifest_applied.txt, и он по построению не мог
|
||||
покраснеть — «файл, которого нет в списке» и «новый файл этого PR» были для
|
||||
теста одним и тем же, поэтому забытое имя навсегда оставалось зелёным
|
||||
(замер 2026-08-07: 15 забытых имён, сьют зелёный).
|
||||
|
||||
ПОЧЕМУ ТОЧКА ВЕТВЛЕНИЯ, А НЕ САМ origin/main. Ветка, отведённая неделю назад,
|
||||
не содержит миграций, смерженных после неё. Правило «origin/main ⊆ рабочее
|
||||
дерево» красило бы каждую такую ветку без вины автора — и его отключили бы
|
||||
через неделю. Сверка с merge-base ловит ровно то, что удалила или
|
||||
переименовала ЭТА ветка, а номера при этом сверяются с ПОЛНЫМ origin/main,
|
||||
чтобы коллизия с миграцией, смерженной после ветвления, всё-таки нашлась.
|
||||
|
||||
КАК УБЕДИТЬСЯ, ЧТО СТОРОЖ УМЕЕТ КРАСНЕТЬ (не на слово):
|
||||
test_collision_rule_flags_a_taken_number ниже проверяет само правило на
|
||||
литеральных входах, а сквозной прогон воспроизводится так —
|
||||
git worktree add --detach /tmp/wt <sha до нужной миграции>
|
||||
touch /tmp/wt/tradein-mvp/backend/data/sql/<занятый-NN>_probe.sql
|
||||
(cd /tmp/wt/tradein-mvp/backend && pytest tests/test_migration_numbering.py)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
_TESTS_DIR = Path(__file__).resolve().parent
|
||||
_SQL_DIR = _TESTS_DIR.parent / "data" / "sql"
|
||||
_REPO_ROOT = _TESTS_DIR.parents[2]
|
||||
# Путь каталога ОТ КОРНЯ РЕПО — им адресуем дерево коммита через git ls-tree.
|
||||
_SQL_PATHSPEC = "tradein-mvp/backend/data/sql"
|
||||
|
||||
_NN_PREFIX = re.compile(r"^(\d+)_")
|
||||
# origin — штатный remote; forgejo/main оставлен как исторический алиас.
|
||||
_MAIN_REFS = ("origin/main", "forgejo/main", "main")
|
||||
|
||||
|
||||
def _git(*args: str) -> str | None:
|
||||
"""stdout git-команды, либо None если git недоступен/команда упала."""
|
||||
try:
|
||||
done = subprocess.run(
|
||||
# Фиксированный argv, без shell — args приходят только из этого модуля.
|
||||
["git", "-C", str(_REPO_ROOT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return done.stdout if done.returncode == 0 else None
|
||||
|
||||
|
||||
def _sql_names_at(rev: str) -> set[str]:
|
||||
"""Bare-имена *.sql в data/sql на ревизии rev."""
|
||||
out = _git("ls-tree", "-r", "-z", "--name-only", rev, "--", _SQL_PATHSPEC) or ""
|
||||
return {PurePosixPath(p).name for p in out.split("\0") if p.endswith(".sql")}
|
||||
|
||||
|
||||
def _sql_names_on_disk() -> set[str]:
|
||||
"""Bare-имена *.sql в рабочем дереве — включая ещё не закоммиченные."""
|
||||
return {p.name for p in _SQL_DIR.glob("*.sql")}
|
||||
|
||||
|
||||
def _prefix(name: str) -> str | None:
|
||||
m = _NN_PREFIX.match(name)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _collisions(new_names: Iterable[str], universe: Iterable[str]) -> list[str]:
|
||||
"""Для каждого НОВОГО имени — чужие имена с тем же NN-префиксом."""
|
||||
by_prefix: dict[str, set[str]] = {}
|
||||
for name in universe:
|
||||
p = _prefix(name)
|
||||
if p is not None:
|
||||
by_prefix.setdefault(p, set()).add(name)
|
||||
|
||||
found: list[str] = []
|
||||
for name in sorted(new_names):
|
||||
p = _prefix(name)
|
||||
if p is None:
|
||||
continue
|
||||
others = sorted(by_prefix.get(p, set()) - {name})
|
||||
if others:
|
||||
found.append(f"{name} — номер {p} уже занят: {', '.join(others)}")
|
||||
return found
|
||||
|
||||
|
||||
def _baseline() -> tuple[set[str], set[str]]:
|
||||
"""(имена в точке ветвления, имена в main). Без git-эталона проверять нечего."""
|
||||
main_ref = next(
|
||||
(r for r in _MAIN_REFS if _git("rev-parse", "--verify", "--quiet", f"{r}^{{commit}}")),
|
||||
None,
|
||||
)
|
||||
merge_base = None
|
||||
if main_ref is not None:
|
||||
out = _git("merge-base", main_ref, "HEAD")
|
||||
merge_base = out.strip() if out else None
|
||||
|
||||
if main_ref is None or merge_base is None:
|
||||
why = (
|
||||
f"нет git-эталона миграций: ни один из {_MAIN_REFS} не резолвится либо у него "
|
||||
f"нет общего предка с HEAD (repo={_REPO_ROOT}). Лечится "
|
||||
"`git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main` "
|
||||
"и НЕ shallow-клоном (нужен общий предок)."
|
||||
)
|
||||
# В CI это не «нечего проверять», а сломанный гейт: пропуск здесь и есть
|
||||
# тот зелёный, который ничего не проверяет. Поэтому красим.
|
||||
if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"):
|
||||
pytest.fail(why)
|
||||
pytest.skip(why)
|
||||
|
||||
base_names = _sql_names_at(merge_base)
|
||||
main_names = _sql_names_at(main_ref)
|
||||
# Анти-вакуум: пустой эталон сделал бы обе проверки зелёными всегда.
|
||||
# Ровно так ломается сторож, если _SQL_PATHSPEC разъедется с раскладкой репо.
|
||||
assert base_names, (
|
||||
f"эталон пуст: git ls-tree {merge_base} -- {_SQL_PATHSPEC} не вернул ни одного "
|
||||
"*.sql. Проверка номеров была бы вакуумно-зелёной — почини путь."
|
||||
)
|
||||
assert main_names, f"в {main_ref} не найдено *.sql по пути {_SQL_PATHSPEC} — то же самое."
|
||||
return base_names, main_names
|
||||
|
||||
|
||||
def test_applied_migration_is_not_renamed_or_deleted() -> None:
|
||||
"""Файл, существовавший в точке ветвления, обязан существовать и сейчас.
|
||||
|
||||
Red => эта ветка переименовала или удалила миграцию, которую прод уже
|
||||
применил и помнит по СТАРОМУ имени. Верни исходное имя; нужно поправить
|
||||
поведение — заводи новый файл с новым номером.
|
||||
"""
|
||||
on_disk = _sql_names_on_disk()
|
||||
assert on_disk, f"не найдено *.sql в {_SQL_DIR}"
|
||||
base_names, _ = _baseline()
|
||||
|
||||
gone = sorted(base_names - on_disk)
|
||||
assert not gone, (
|
||||
f"миграции пропали из {_SQL_PATHSPEC}/ (переименованы или удалены): {gone}. "
|
||||
"Прод трекает их по bare-filename в _schema_migrations — под новым именем "
|
||||
"миграция прогонится повторно. Верни имена как были."
|
||||
)
|
||||
|
||||
|
||||
def test_new_migration_takes_a_free_number() -> None:
|
||||
"""Новый файл не переиспользует NN, занятый в origin/main или в этой ветке.
|
||||
|
||||
Red => номер уже занят. Возьми следующий свободный, сверяясь с origin/main:
|
||||
|
||||
git fetch origin main
|
||||
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
|
||||
|
||||
`-r` обязателен: без него ls-tree печатает сам каталог, а не файлы (в этом
|
||||
виде рецепт и ходил по issue #2683 — и молча возвращал одну строку).
|
||||
Локального `ls` недостаточно: он не видит миграций, смерженных после
|
||||
ветвления — ровно так разъехались 212 в #2682 и 234 в #2754.
|
||||
"""
|
||||
on_disk = _sql_names_on_disk()
|
||||
base_names, main_names = _baseline()
|
||||
|
||||
new_names = on_disk - base_names
|
||||
problems = _collisions(new_names, main_names | on_disk)
|
||||
assert not problems, "коллизия номеров миграций: " + "; ".join(problems)
|
||||
|
||||
|
||||
def test_collision_rule_flags_a_taken_number() -> None:
|
||||
"""Проверка самого правила — сторож обязан уметь краснеть (#2683 п.6).
|
||||
|
||||
Ожидания здесь — ЛИТЕРАЛЫ, а не производные от содержимого data/sql: тест,
|
||||
который берёт ожидание из охраняемой настройки, зелен при любой настройке.
|
||||
"""
|
||||
# Реальный случай #2754: ветка отвелась до того, как в main приехал 234_scrape.
|
||||
assert _collisions(
|
||||
["234_trade_in_estimates_retain_until.sql"],
|
||||
{
|
||||
"233_payments.sql",
|
||||
"234_scrape_runs_ban_kind_unknown.sql",
|
||||
"234_trade_in_estimates_retain_until.sql",
|
||||
},
|
||||
) == [
|
||||
"234_trade_in_estimates_retain_until.sql — номер 234 уже занят: "
|
||||
"234_scrape_runs_ban_kind_unknown.sql"
|
||||
]
|
||||
# Два новых файла с одним номером внутри одной ветки — оба названы.
|
||||
assert len(_collisions(["300_a.sql", "300_b.sql"], {"300_a.sql", "300_b.sql"})) == 2
|
||||
# Свободный номер — тишина; исторические дубли не новые и не флагаются.
|
||||
assert _collisions(["300_a.sql"], {"084_x.sql", "084_y.sql", "300_a.sql"}) == []
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
"""Invariants over data/sql migrations (issue #2216).
|
||||
|
||||
Прод применяет миграции по BARE FILENAME: deploy-tradein.yml трекает каждый
|
||||
`data/sql/*.sql` в таблице `public._schema_migrations` (PRIMARY KEY = filename).
|
||||
Из этого следуют два хрупких инварианта, которые этот тест защищает от регрессии:
|
||||
|
||||
1. Переименование / удаление УЖЕ ПРИМЕНЁННОЙ миграции ломает прод: новый
|
||||
filename считается неприменённым и прогоняется повторно (дубль-INSERT,
|
||||
повторный DDL, PK violation под ON_ERROR_STOP => деплой падает или, хуже,
|
||||
молча дублирует данные). Manifest `data/sql/_manifest_applied.txt` — это
|
||||
слепок применённых имён на момент коммита; любой из них ОБЯЗАН существовать.
|
||||
|
||||
2. Два разных файла с одинаковым NN-префиксом ("дубль-префикс") — источник
|
||||
двусмысленного порядка применения (`ls | sort` детерминирован, но человек
|
||||
легко создаёт коллизию). 6 исторических дублей grandfathered'ы (оба в
|
||||
manifest). Любой НОВЫЙ файл обязан нести уникальный префикс.
|
||||
|
||||
Self-maintenance: добавляя новую миграцию, допиши её имя в manifest В ТОМ ЖЕ PR
|
||||
(см. assert-сообщения ниже). Тест требует data/sql ⊇ manifest и уникальность
|
||||
префикса у новых файлов; сам manifest дополняет автор миграции.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
_SQL_DIR = _BACKEND_ROOT / "data" / "sql"
|
||||
_MANIFEST = _SQL_DIR / "_manifest_applied.txt"
|
||||
|
||||
_NN_PREFIX = re.compile(r"^(\d+)_")
|
||||
|
||||
|
||||
def _read_manifest() -> list[str]:
|
||||
"""Имена миграций из manifest; # comments и пустые строки игнорируются."""
|
||||
names: list[str] = []
|
||||
for raw in _MANIFEST.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
names.append(line)
|
||||
return names
|
||||
|
||||
|
||||
def _actual_sql_files() -> set[str]:
|
||||
return {p.name for p in _SQL_DIR.glob("*.sql")}
|
||||
|
||||
|
||||
def _prefix(name: str) -> str | None:
|
||||
m = _NN_PREFIX.match(name)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def test_manifest_entries_all_exist() -> None:
|
||||
"""Каждый файл из manifest СУЩЕСТВУЕТ в data/sql/.
|
||||
|
||||
Red => применённая миграция переименована или удалена. Прод трекает по
|
||||
bare-filename: старое имя остаётся в _schema_migrations, НОВОЕ имя считается
|
||||
неприменённым и прогоняется повторно. Восстанови исходное имя файла (или,
|
||||
если переименование намеренное — так делать НЕЛЬЗЯ для уже-применённых
|
||||
миграций: заведи НОВЫЙ файл, а старый оставь как есть).
|
||||
"""
|
||||
actual = _actual_sql_files()
|
||||
manifest = _read_manifest()
|
||||
missing = sorted(n for n in manifest if n not in actual)
|
||||
assert not missing, (
|
||||
"Миграции из _manifest_applied.txt отсутствуют в data/sql/ "
|
||||
f"(переименованы/удалены?): {missing}. Эти имена уже применены на проде "
|
||||
"(tracking по bare-filename в _schema_migrations) — их нельзя "
|
||||
"переименовывать/удалять. Верни исходные имена файлов."
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_is_sorted_and_unique() -> None:
|
||||
"""Manifest отсортирован (codepoint) и без дублей — детерминированный слепок."""
|
||||
manifest = _read_manifest()
|
||||
assert manifest == sorted(manifest), (
|
||||
"_manifest_applied.txt не отсортирован. Пересортируй записи "
|
||||
"(LC_ALL=C sort / Python sorted())."
|
||||
)
|
||||
dupes = sorted({n for n in manifest if manifest.count(n) > 1})
|
||||
assert not dupes, f"Дублирующиеся строки в _manifest_applied.txt: {dupes}"
|
||||
|
||||
|
||||
def test_new_files_do_not_reuse_prefix() -> None:
|
||||
"""Новые (не в manifest) .sql-файлы НЕ переиспользуют существующий NN-префикс.
|
||||
|
||||
Grandfathered дубли (084/108/113/121/124/130) — оба файла в manifest, поэтому
|
||||
не флагаются: считаются "существующими", а не "новыми".
|
||||
|
||||
Red => новый файл взял префикс уже присутствующей миграции. Присвой
|
||||
следующий свободный NN и допиши имя в _manifest_applied.txt (тот же PR).
|
||||
"""
|
||||
actual = _actual_sql_files()
|
||||
manifest = set(_read_manifest())
|
||||
|
||||
# Префиксы, «занятые» уже-применёнными (manifest) миграциями.
|
||||
baseline_prefixes: set[str] = set()
|
||||
for name in manifest:
|
||||
p = _prefix(name)
|
||||
if p is not None:
|
||||
baseline_prefixes.add(p)
|
||||
|
||||
new_files = sorted(actual - manifest)
|
||||
collisions: list[str] = []
|
||||
# Внутри новых файлов префикс тоже обязан быть уникален (два новых с одним NN).
|
||||
seen_new_prefix: dict[str, str] = {}
|
||||
for name in new_files:
|
||||
p = _prefix(name)
|
||||
if p is None:
|
||||
continue
|
||||
if p in baseline_prefixes:
|
||||
collisions.append(f"{name} (префикс {p} занят применённой миграцией)")
|
||||
elif p in seen_new_prefix:
|
||||
collisions.append(f"{name} (префикс {p} уже у нового {seen_new_prefix[p]})")
|
||||
else:
|
||||
seen_new_prefix[p] = name
|
||||
|
||||
assert not collisions, (
|
||||
"Новые миграции переиспользуют NN-префикс: " + "; ".join(collisions) + ". "
|
||||
"Присвой следующий свободный номер и добавь имя файла в "
|
||||
"_manifest_applied.txt в ЭТОМ ЖЕ PR."
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_covers_all_but_new_files() -> None:
|
||||
"""data/sql ⊇ manifest, и каждый новый файл имеет уникальный префикс —
|
||||
напоминание о self-maintenance: manifest дополняется вместе с миграцией.
|
||||
|
||||
Этот тест НЕ требует, чтобы новый файл уже был в manifest (иначе PR с новой
|
||||
миграцией всегда красный). Он лишь гарантирует, что manifest не отстал от
|
||||
реальности В ЧАСТИ применённых имён (см. test_manifest_entries_all_exist) и
|
||||
что новые файлы не создают префикс-коллизий (см. предыдущий тест).
|
||||
"""
|
||||
# Sanity: manifest непустой и в data/sql есть файлы — защита от битых путей.
|
||||
assert _actual_sql_files(), f"Не найдено *.sql в {_SQL_DIR}"
|
||||
assert _read_manifest(), f"_manifest_applied.txt пуст: {_MANIFEST}"
|
||||
|
|
@ -149,6 +149,98 @@ def test_sliding_window_limiter_per_key_isolation():
|
|||
assert limiter.retry_after("bob") is None # свой ключ — не задет alice
|
||||
|
||||
|
||||
# ── Платёжная нотификация — мимо ОБЩЕГО лимитера (PR-D2, критерий приёмки #3) ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notify_client(monkeypatch):
|
||||
"""То же минимальное приложение, что `client`, но лимит анонима искусственно
|
||||
крошечный (1/60с) — если бы notify-путь шёл через общий лимитер, 2-й запрос
|
||||
уже получил бы 429. Плюс контрольный `/api/v1/ping` — доказывает, что
|
||||
лимитер в принципе активен (не выключен целиком), просто notify мимо него."""
|
||||
monkeypatch.setattr(config.settings, "rate_limit", 1)
|
||||
monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0)
|
||||
monkeypatch.setattr(config.settings, "rate_limit_authenticated_multiplier", 2)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
|
||||
@app.get("/api/v1/ping")
|
||||
def ping() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/v1/trade-in/payments/notify")
|
||||
def notify() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_notify_path_bypasses_general_limiter_400_requests_zero_429(notify_client):
|
||||
"""PR-D2 acceptance criteria: 400 запросов к notify с одного адреса за минуту
|
||||
не дают ни одного отказа по частоте — даже с общим лимитом искусственно
|
||||
зажатым до 1/60с (см. фикстуру)."""
|
||||
statuses = [
|
||||
notify_client.post("/api/v1/trade-in/payments/notify").status_code for _ in range(400)
|
||||
]
|
||||
assert all(
|
||||
code == 200 for code in statuses
|
||||
), f"notify получил 429 хотя бы раз: {[c for c in statuses if c != 200]}"
|
||||
|
||||
|
||||
def test_general_limiter_still_active_for_other_paths(notify_client):
|
||||
"""Контроль: общий лимитер НЕ выключен целиком — обычный /api/v1/ping с тем
|
||||
же крошечным лимитом (1/60с) отбивается на 2-м запросе как обычно. Доказывает,
|
||||
что notify-bypass узкий (точный путь), а не побочный эффект общей поломки."""
|
||||
assert notify_client.get("/api/v1/ping").status_code == 200
|
||||
assert notify_client.get("/api/v1/ping").status_code == 429
|
||||
|
||||
|
||||
def test_notify_bypass_has_own_dedicated_limiter_not_unlimited():
|
||||
"""notify НЕ отключён от лимитера вовсе — своя щедрая, но конечная корзина
|
||||
(`_notify_limiter`, идиома `SlidingWindowLimiter` из `support.py`). Проверяем
|
||||
напрямую: исчерпать маленький искусственный лимит и убедиться, что backstop
|
||||
таки срабатывает (защита от полного disable вместо узкого бюджета)."""
|
||||
from app.core import ratelimit as ratelimit_module
|
||||
|
||||
limiter = ratelimit_module.SlidingWindowLimiter(limit=2, window_s=60.0)
|
||||
assert limiter.check("1.2.3.4") is None
|
||||
assert limiter.check("1.2.3.4") is None
|
||||
# 3-й запрос того же ключа — уже за лимитом (backstop жив).
|
||||
assert limiter.check("1.2.3.4") is not None
|
||||
|
||||
|
||||
def test_notify_limiter_key_is_per_ip_not_global(monkeypatch):
|
||||
"""Бюджет notify — per-IP (не общий на все входящие сразу), тот же принцип
|
||||
ключа, что общий лимитер использует для анонимного трафика."""
|
||||
monkeypatch.setattr(config.settings, "rate_limit", 300)
|
||||
monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0)
|
||||
|
||||
from app.core import ratelimit as ratelimit_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
ratelimit_module,
|
||||
"_notify_limiter",
|
||||
ratelimit_module.SlidingWindowLimiter(limit=1, window_s=60.0),
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
|
||||
@app.post("/api/v1/trade-in/payments/notify")
|
||||
def notify() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
# Первый запрос с IP #1 — проходит, второй с тем же IP — 429 (лимит=1).
|
||||
headers_ip1 = {"X-Forwarded-For": "1.1.1.1"}
|
||||
headers_ip2 = {"X-Forwarded-For": "2.2.2.2"}
|
||||
assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip1).status_code == 200
|
||||
assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip1).status_code == 429
|
||||
# Другой IP — своя, независимая корзина.
|
||||
assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip2).status_code == 200
|
||||
|
||||
|
||||
def test_sliding_window_limiter_prunes_empty_buckets_past_threshold():
|
||||
"""review L2: пустые корзины чистятся при накоплении >10000 ключей (тот же
|
||||
паттерн, что `RateLimitMiddleware.dispatch`) — не бесконечная утечка памяти.
|
||||
|
|
|
|||
|
|
@ -262,6 +262,37 @@ def test_login_event_type_when_request_succeeds(client: TestClient) -> None:
|
|||
assert login_calls[0].kwargs["payload"] == {"status_code": 200}
|
||||
|
||||
|
||||
# ── Платёжная нотификация — вне аудита (PR-D2, критерий приёмки #4) ─────────
|
||||
|
||||
|
||||
def test_payments_notify_path_skips_audit_even_with_spoofed_admin_header() -> None:
|
||||
"""PR-D2 acceptance criteria: запрос к нотификации не создаёт записей в
|
||||
журнале аудита — даже с заголовком `X-Authenticated-User: admin`. Middleware
|
||||
— ВНЕШНИЙ относительно rbac_guard и читает сырой заголовок напрямую (см.
|
||||
docstring `request_audit.py`), так что анонимный POST со спуфнутым
|
||||
заголовком иначе писал бы фальшивое `login`/`api_request` событие с
|
||||
атрибуцией admin, хотя rbac этот путь пока (до PR-D3) закрывает 401'ом
|
||||
отдельно и независимо от этого middleware."""
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestAuditMiddleware)
|
||||
|
||||
@app.post("/api/v1/trade-in/payments/notify")
|
||||
def notify() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
with (
|
||||
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||
patch("app.core.request_audit.should_log_login", return_value=True),
|
||||
):
|
||||
resp = TestClient(app).post(
|
||||
"/api/v1/trade-in/payments/notify",
|
||||
headers={"X-Authenticated-User": "admin"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_schedule.assert_not_called()
|
||||
|
||||
|
||||
def test_login_failed_event_type_when_rbac_rejects_request() -> None:
|
||||
"""Ответ >= 400 (напр. RBAC-отказ downstream: неизвестная роль / протухший
|
||||
внутренний секрет) -> event_type='login_failed', а НЕ 'login' — раньше эти
|
||||
|
|
|
|||
111
tradein-mvp/backend/tests/test_sentry_init_wiring.py
Normal file
111
tradein-mvp/backend/tests/test_sentry_init_wiring.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""PR-D2 (платёжный периметр): каждая точка инициализации `sentry_sdk.init(...)`
|
||||
в проекте обязана проводить ОБА канала мониторинга — `before_send` (error-события)
|
||||
и `before_send_transaction` (performance-трейсы). Мотивирующий инцидент (соседний
|
||||
продукт, Птица, вчера): закрыли только error-канал через `before_send`, а
|
||||
`before_send_transaction` остался вообще без обработчика — очистка body/PII там
|
||||
не применялась.
|
||||
|
||||
Инициализация происходит на module-level внутри `if settings.glitchtip_dsn:` —
|
||||
поведенческий тест потребовал бы реального импорта модуля с DSN, выставленным
|
||||
ДО импорта (модуль кэшируется, monkeypatch settings после импорта на init уже не
|
||||
влияет), плюс `sentry_sdk.init` — процесс-глобальный singleton (повторные вызовы
|
||||
из разных тестов друг друга затирают). Вместо этого — статический разбор AST:
|
||||
детерминирован, не трогает process-global state, не зависит от порядка тестов.
|
||||
|
||||
НЕ grep/substring по тексту файла: `before_send_transaction` уже упоминается в
|
||||
docstring-комментариях этих же файлов (объясняющих МОТИВ) — substring-поиск дал
|
||||
бы ложный PASS без единой реальной проводки в `sentry_sdk.init(...)`. Разбор
|
||||
именно keyword-аргументов AST Call-узла `sentry_sdk.init(...)` не подвержен
|
||||
этому false positive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_APP_DIR = Path(__file__).resolve().parent.parent / "app"
|
||||
|
||||
# Все известные точки инициализации sentry_sdk в проекте (backend API, scraper
|
||||
# scheduler, telegram support-bridge). Список сверяется отдельным тестом ниже
|
||||
# против грепа по всему `app/`, чтобы новая точка инициализации не прошла мимо
|
||||
# этого файла молча.
|
||||
_SENTRY_INIT_FILES = ["main.py", "scheduler_main.py", "tgbot_main.py"]
|
||||
|
||||
|
||||
def _sentry_init_calls(source: str, filename: str) -> list[ast.Call]:
|
||||
"""Все AST Call-узлы вида `sentry_sdk.init(...)` в модуле."""
|
||||
tree = ast.parse(source, filename=filename)
|
||||
calls = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "init"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id == "sentry_sdk"
|
||||
):
|
||||
calls.append(node)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", _SENTRY_INIT_FILES)
|
||||
def test_sentry_init_wires_both_channels(filename: str) -> None:
|
||||
source = (_APP_DIR / filename).read_text(encoding="utf-8")
|
||||
calls = _sentry_init_calls(source, filename)
|
||||
assert calls, f"{filename}: sentry_sdk.init(...) call not found (файл переехал?)"
|
||||
for call in calls:
|
||||
kwarg_names = {kw.arg for kw in call.keywords if kw.arg is not None}
|
||||
assert "before_send" in kwarg_names, (
|
||||
f"{filename}: sentry_sdk.init(...) не передаёт before_send — "
|
||||
"error-канал уходит в GlitchTip без scrub"
|
||||
)
|
||||
assert "before_send_transaction" in kwarg_names, (
|
||||
f"{filename}: sentry_sdk.init(...) не передаёт before_send_transaction — "
|
||||
"transaction-канал уходит в GlitchTip без scrub (ровно вчерашний баг Птицы)"
|
||||
)
|
||||
|
||||
|
||||
def test_sentry_init_before_send_and_transaction_use_same_handler() -> None:
|
||||
"""`before_send` и `before_send_transaction` обязаны указывать на ОДИН и тот
|
||||
же обработчик (одинаковое имя переменной/функции в keyword-значении) — иначе
|
||||
возможен регресс, при котором кто-то поправит один канал и забудет второй,
|
||||
хотя формально оба параметра присутствуют."""
|
||||
for filename in _SENTRY_INIT_FILES:
|
||||
source = (_APP_DIR / filename).read_text(encoding="utf-8")
|
||||
calls = _sentry_init_calls(source, filename)
|
||||
for call in calls:
|
||||
kwargs = {kw.arg: kw.value for kw in call.keywords if kw.arg is not None}
|
||||
before_send = kwargs.get("before_send")
|
||||
before_send_txn = kwargs.get("before_send_transaction")
|
||||
assert before_send is not None and before_send_txn is not None
|
||||
# Оба значения — ссылки на имя (ast.Name), сравниваем идентификатор.
|
||||
assert isinstance(before_send, ast.Name)
|
||||
assert isinstance(before_send_txn, ast.Name)
|
||||
assert before_send.id == before_send_txn.id, (
|
||||
f"{filename}: before_send={before_send.id!r} != "
|
||||
f"before_send_transaction={before_send_txn.id!r} — разные обработчики "
|
||||
"на двух каналах, ровно тот класс бага, что и голый пропуск канала"
|
||||
)
|
||||
|
||||
|
||||
def test_all_sentry_init_call_sites_are_enumerated() -> None:
|
||||
"""Если кто-то добавит НОВУЮ точку инициализации sentry_sdk.init(...) где-то
|
||||
ещё в app/ — этот тест должен упасть, а не молча пропустить её мимо теста
|
||||
выше (список `_SENTRY_INIT_FILES` — руками поддерживаемый allowlist)."""
|
||||
found_files = set()
|
||||
for py_file in _APP_DIR.rglob("*.py"):
|
||||
source = py_file.read_text(encoding="utf-8")
|
||||
if _sentry_init_calls(source, str(py_file)):
|
||||
found_files.add(py_file.relative_to(_APP_DIR).as_posix())
|
||||
|
||||
expected = set(_SENTRY_INIT_FILES)
|
||||
assert found_files == expected, (
|
||||
f"Точки инициализации sentry_sdk.init(...) разошлись со списком в тесте: "
|
||||
f"найдено {sorted(found_files)}, ожидалось {sorted(expected)}. Новую точку "
|
||||
"нужно добавить в _SENTRY_INIT_FILES ЭТОГО файла и проверить оба канала."
|
||||
)
|
||||
|
|
@ -14,6 +14,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
|
||||
from app.observability.sentry_scrub import (
|
||||
redact_telegram_bot_token,
|
||||
scrub_payment_request_body,
|
||||
scrub_pii_event,
|
||||
stabilize_retry_error_fingerprint,
|
||||
)
|
||||
|
|
@ -232,6 +233,128 @@ def test_bare_token_redaction_leaves_benign_colon_strings_untouched(benign: str)
|
|||
assert out["logentry"]["message"] == benign
|
||||
|
||||
|
||||
# ── Платёжный body-wipe (PR-D2, критерий приёмки #1) ─────────────────────────
|
||||
|
||||
|
||||
def test_scrub_payment_request_body_removes_data_for_payments_path() -> None:
|
||||
"""Событие мониторинга с адресом платёжного пути и телом, содержащим `Token`
|
||||
и `Pan`, уходит БЕЗ ключа с телом (PR-D2 acceptance criteria)."""
|
||||
event = {
|
||||
"request": {
|
||||
"url": "https://gendsgn.ru/api/v1/trade-in/payments/notify",
|
||||
"data": {
|
||||
"Token": "deadbeefdeadbeefdeadbeef",
|
||||
"Pan": "220000******0000",
|
||||
"ExpDate": "1230",
|
||||
"CardId": "123456",
|
||||
"RebillId": "987654",
|
||||
"DATA": {"Email": "someone@example.com"},
|
||||
},
|
||||
"method": "POST",
|
||||
}
|
||||
}
|
||||
out = scrub_payment_request_body(event, {})
|
||||
assert out is not None
|
||||
assert "data" not in out["request"]
|
||||
# Остальные поля request не тронуты.
|
||||
assert out["request"]["method"] == "POST"
|
||||
assert out["request"]["url"] == "https://gendsgn.ru/api/v1/trade-in/payments/notify"
|
||||
|
||||
|
||||
def test_scrub_payment_request_body_covers_checkout_too() -> None:
|
||||
"""Матч по сегменту пути, не по конкретному эндпоинту — checkout тоже режется."""
|
||||
event = {
|
||||
"request": {
|
||||
"url": "https://gendsgn.ru/api/v1/trade-in/payments/checkout",
|
||||
"data": {"consent": True, "product_code": "report_pdf"},
|
||||
}
|
||||
}
|
||||
out = scrub_payment_request_body(event, {})
|
||||
assert out is not None
|
||||
assert "data" not in out["request"]
|
||||
|
||||
|
||||
def test_scrub_payment_request_body_case_insensitive_url_match() -> None:
|
||||
"""Регистр URL не должен позволять данным проскочить — Caddy/rbac регистр
|
||||
трактуют по-разному, страховка на случай, если событие всё же породилось."""
|
||||
event = {
|
||||
"request": {
|
||||
"url": "https://gendsgn.ru/API/V1/Trade-In/Payments/Notify",
|
||||
"data": {"Token": "secret"},
|
||||
}
|
||||
}
|
||||
out = scrub_payment_request_body(event, {})
|
||||
assert out is not None
|
||||
assert "data" not in out["request"]
|
||||
|
||||
|
||||
def test_scrub_payment_request_body_leaves_other_paths_untouched() -> None:
|
||||
"""Не платёжный путь — тело остаётся (это не общий kill-switch на request.data)."""
|
||||
event = {
|
||||
"request": {
|
||||
"url": "https://gendsgn.ru/api/v1/trade-in/estimate",
|
||||
"data": {"area_sqm": 50, "region": "66"},
|
||||
}
|
||||
}
|
||||
out = scrub_payment_request_body(event, {})
|
||||
assert out is not None
|
||||
assert out["request"]["data"] == {"area_sqm": 50, "region": "66"}
|
||||
|
||||
|
||||
def test_scrub_payment_request_body_handles_missing_request() -> None:
|
||||
out = scrub_payment_request_body({"level": "error"}, {})
|
||||
assert out == {"level": "error"}
|
||||
|
||||
|
||||
def test_scrub_payment_request_body_handles_non_dict_event() -> None:
|
||||
assert scrub_payment_request_body(None, {}) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_scrub_payment_request_body_handles_missing_url() -> None:
|
||||
"""`request` без `url` (нестандартный event) — не бросает, тело не трогает."""
|
||||
event = {"request": {"data": {"Token": "x"}}}
|
||||
out = scrub_payment_request_body(event, {})
|
||||
assert out is not None
|
||||
assert out["request"]["data"] == {"Token": "x"}
|
||||
|
||||
|
||||
# ── Расширенный набор платёжных PII-ключей (PR-D2, критерий приёмки #2) ──────
|
||||
|
||||
|
||||
def test_pii_keys_scrub_payment_fields_at_arbitrary_depth() -> None:
|
||||
"""Скрабер вычищает `customer_email`/`customer_phone`/платёжные поля на
|
||||
произвольной глубине вложенности (PR-D2 acceptance criteria)."""
|
||||
event = {
|
||||
"extra": {
|
||||
"checkout_context": {
|
||||
"buyer": {
|
||||
"customer_email": "buyer@example.com",
|
||||
"customer_phone": "+79991234567",
|
||||
"nested_list": [
|
||||
{"pan": "220000******1111", "expdate": "0129"},
|
||||
{"cardid": "abc123", "rebillid": "xyz789"},
|
||||
],
|
||||
},
|
||||
"token": "sensitive-token-value",
|
||||
"terminalkey": "TinkoffBankTest",
|
||||
"order_id": "ord_123",
|
||||
}
|
||||
}
|
||||
}
|
||||
out = scrub_pii_event(event, {})
|
||||
ctx = out["extra"]["checkout_context"]
|
||||
assert ctx["buyer"]["customer_email"] == "[REDACTED]"
|
||||
assert ctx["buyer"]["customer_phone"] == "[REDACTED]"
|
||||
assert ctx["buyer"]["nested_list"][0]["pan"] == "[REDACTED]"
|
||||
assert ctx["buyer"]["nested_list"][0]["expdate"] == "[REDACTED]"
|
||||
assert ctx["buyer"]["nested_list"][1]["cardid"] == "[REDACTED]"
|
||||
assert ctx["buyer"]["nested_list"][1]["rebillid"] == "[REDACTED]"
|
||||
assert ctx["token"] == "[REDACTED]"
|
||||
assert ctx["terminalkey"] == "[REDACTED]"
|
||||
# non-PII поле остаётся.
|
||||
assert ctx["order_id"] == "ord_123"
|
||||
|
||||
|
||||
def test_composed_before_send_scrubs_pii_and_token_together() -> None:
|
||||
"""Композиция, реально используемая в `app.tgbot_main._before_send`: PII-scrub
|
||||
(ключ-based) И token-redaction (regex full-text) применяются оба, не заменяя
|
||||
|
|
@ -262,6 +385,37 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None:
|
|||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
|
||||
|
||||
|
||||
def test_composed_before_send_payment_wipe_pii_and_token_together() -> None:
|
||||
"""Полная композиция `app.main._before_send` (PR-D2): body-wipe для платёжного
|
||||
пути → PII-scrub → token-redaction, в этом порядке, все три применяются."""
|
||||
event = {
|
||||
"request": {
|
||||
"url": "https://gendsgn.ru/api/v1/trade-in/payments/notify",
|
||||
"data": {"Token": "deadbeef", "Pan": "220000******0000"},
|
||||
},
|
||||
"extra": {"client_phone": "+79991234567"},
|
||||
"exception": {
|
||||
"values": [{"stacktrace": {"frames": [{"vars": {"url": _LEAKED_TOKEN_URL}}]}}]
|
||||
},
|
||||
}
|
||||
|
||||
def composed_before_send(evt, hint):
|
||||
scrubbed = scrub_payment_request_body(evt, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
||||
if scrubbed is None:
|
||||
return None
|
||||
return redact_telegram_bot_token(scrubbed, hint)
|
||||
|
||||
out = composed_before_send(event, {})
|
||||
assert out is not None
|
||||
assert "data" not in out["request"]
|
||||
assert out["extra"]["client_phone"] == "[REDACTED]"
|
||||
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
|
||||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
|
||||
|
||||
|
||||
# ── RetryError fingerprint stabilization (glitchtip-noise, #<GlitchTip triage>) ─
|
||||
#
|
||||
# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address
|
||||
|
|
|
|||
|
|
@ -636,6 +636,19 @@ def save_listings(
|
|||
is_rosreestr_checked = COALESCE(
|
||||
EXCLUDED.is_rosreestr_checked, listings.is_rosreestr_checked
|
||||
),
|
||||
-- listing_segment раньше писался ТОЛЬКО при INSERT — строка, единожды
|
||||
-- родившаяся с NULL-сегментом (классификатор не сработал в первый
|
||||
-- скрейп), никогда не самочинилась на повторных сборах, даже когда
|
||||
-- сегмент становился определим. Симптом лечили отдельной джобой
|
||||
-- деактивации (deactivate_stale_{cian,yandex}_null_segment,
|
||||
-- миграция 266, PR #2908) — это чистит мусор, но не устраняет причину.
|
||||
-- COALESCE, а не голый EXCLUDED: если СЕЙЧАС скрейп снова не смог
|
||||
-- определить сегмент (EXCLUDED.listing_segment IS NULL), нельзя затирать
|
||||
-- уже известное значение пустым — та же защита, что и для
|
||||
-- city/kitchen/ceiling выше.
|
||||
listing_segment = COALESCE(
|
||||
EXCLUDED.listing_segment, listings.listing_segment
|
||||
),
|
||||
-- Yandex rich fields (+ shared description/agency/publish_date):
|
||||
-- COALESCE so a source that does not provide them never wipes
|
||||
-- a value previously written by another source / scrape.
|
||||
|
|
@ -738,6 +751,11 @@ def save_listings(
|
|||
is_rosreestr_checked = COALESCE(
|
||||
:is_rosreestr_checked, is_rosreestr_checked
|
||||
),
|
||||
-- см. ON CONFLICT DO UPDATE выше — тот же self-heal для
|
||||
-- listing_segment, теперь и на reconcile-пути (dedup_hash
|
||||
-- drift). Без этого строки, прошедшие через reconcile,
|
||||
-- остались бы с тем же незалеченным NULL-сегментом.
|
||||
listing_segment = COALESCE(:listing_segment, listing_segment),
|
||||
publish_date = COALESCE(:publish_date, publish_date),
|
||||
days_on_market = COALESCE(:days_on_market, days_on_market),
|
||||
description = COALESCE(:description, description),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue