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