gendesign/tradein-mvp/backend/tests/test_sentry_init_wiring.py
bot-backend 1f85ef7d4e
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Successful in 3m50s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/payments): тело нотификации не течёт в мониторинг и аудит, повторы банка не отбиваются лимитом
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей
(PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile,
roles.yaml, auth_session.py не тронуты.

- sentry_scrub.py: новая scrub_payment_request_body — вырезает
  event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64
  кладёт полное тело запроса в request.data, send_default_pii=False это НЕ
  гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS:
  customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey.
- main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации
  sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала,
  before_send и before_send_transaction. Мотивирующий инцидент: на соседнем
  продукте вчера закрыли только error-канал, transaction остался без
  обработчика вообще.
- ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter
  (3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное
  отключение — backstop против шторма запросов остаётся, подпись проверяется
  уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией).
- request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware
  внешний относительно rbac_guard и читает сырой X-Authenticated-User —
  спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin).
- smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout
  сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401
  (gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра.

Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор
(не substring — комментарии в этих же файлах сами упоминают
before_send_transaction) на проводку обоих каналов во всех точках
инициализации, 400 запросов notify без единого 429 + контроль что общий
лимитер по-прежнему активен на других путях, notify вне user_events даже со
спуфнутым X-Authenticated-User: admin.
2026-08-07 16:17:59 +03:00

111 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 ЭТОГО файла и проверить оба канала."
)