gendesign/tradein-mvp/backend/tests/test_sentry_scrub.py
bot-backend 165c4a5edd
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m59s
fix(obs): убрать шум выключенных платежей и трейсы health-проб proxy_pool
Два источника шума в error-ленте и логах:

1. GlitchTip группа TRADE-IN-3GG: 167 событий за 29.08-12.09 — 503
   "payments are disabled" из payments.py._require_enabled, которые бьёт
   внутренний IP смоук-проверки (кнопки оплаты во фронте нет). sentry_sdk
   StarletteIntegration репортит любой HTTPException с кодом из 5xx как
   error-событие, даже когда FastAPI штатно обработал исключение и вернул
   корректный ответ. Добавлен before_send-фильтр
   drop_payments_disabled_event (app/observability/sentry_scrub.py),
   матчащий по (status_code=503, detail="payments are disabled") через
   hint["exc_info"] — не по коду 503 в целом, чтобы не проглотить другие
   503. Подключён во всех трёх точках инициализации sentry_sdk.init
   (app/main.py — единственный реальный источник события,
   scheduler_main.py и tgbot_main.py — belt-and-suspenders для
   единообразия, по образцу scrub_payment_request_body). Само поведение
   ручки не меняется — 503 остаётся, фильтруется только репортинг в
   трекер.

2. proxy_pool._probe_proxy: httpx.ProxyError (407 от прокси-провайдера)
   не попадал ни под TimeoutException, ни под ConnectError и падал в
   generic except Exception с exc_info=True — 184 строки полного
   traceback в сутки на штатный провал health-пробы, хотя итоговая
   сводка checked/ok/failed и так его учитывает. Добавлена отдельная
   ветка except httpx.ProxyError с логом в одну строку (узел + причина
   текстом исключения, без трейса). Логика самой пробы, аренды узлов и
   правил пула не изменена.

Тесты: tests/test_sentry_scrub.py (drop_payments_disabled_event — дропает
целевой 503, пропускает прочие ошибки и прочие 503/detail-комбинации),
tests/services/test_proxy_pool.py (ProxyError логируется одной строкой
без exc_info, счётчики healthcheck не ломаются).

Refs #3471
2026-09-12 14:03:39 +03:00

690 lines
32 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.

"""Unit-тесты consumer-PII scrubber для GlitchTip (#396) + Telegram bot-токен
redaction (#tgsupport review — CRITICAL: токен утекал в GlitchTip двумя
независимыми векторами, воспроизведёнными ревьюером на реальном событии
sentry_sdk: stack-frame locals (`include_local_variables=True`) и httpx-span
`data` (`HttpxIntegration`). Тесты ниже конструируют event-словари ровно в той
форме, в которой их реально отдаёт sentry_sdk (frames[].vars, spans[].data),
чтобы regression на этот конкретный CRITICAL не проскочил молча."""
import os
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
def test_redacts_pii_in_request_data() -> None:
event = {
"request": {
"data": {
"client_name": "Иван Иванов",
"client_phone": "+79991234567",
"client_email": "ivan@example.com",
"address": "Екатеринбург, ул. Ленина 1",
}
}
}
out = scrub_pii_event(event, {})
data = out["request"]["data"]
assert data["client_name"] == "[REDACTED]"
assert data["client_phone"] == "[REDACTED]"
assert data["client_email"] == "[REDACTED]"
# non-PII поле не трогаем
assert data["address"] == "Екатеринбург, ул. Ленина 1"
def test_redacts_pii_in_extra() -> None:
event = {
"extra": {
"phone": "+79990000000",
"email": "x@y.ru",
"name": "Пётр",
"estimate_id": 42,
}
}
out = scrub_pii_event(event, {})
extra = out["extra"]
assert extra["phone"] == "[REDACTED]"
assert extra["email"] == "[REDACTED]"
assert extra["name"] == "[REDACTED]"
assert extra["estimate_id"] == 42
def test_redact_is_case_insensitive() -> None:
event = {"extra": {"Client_Name": "Анна", "CLIENT_PHONE": "+7900"}}
out = scrub_pii_event(event, {})
assert out["extra"]["Client_Name"] == "[REDACTED]"
assert out["extra"]["CLIENT_PHONE"] == "[REDACTED]"
def test_redacts_nested_pii_in_contexts() -> None:
event = {"contexts": {"trace": {"op": "http"}, "consumer": {"client_email": "z@z.ru"}}}
out = scrub_pii_event(event, {})
assert out["contexts"]["consumer"]["client_email"] == "[REDACTED]"
# вложенный non-PII контекст не трогаем
assert out["contexts"]["trace"]["op"] == "http"
def test_leaves_non_pii_untouched() -> None:
event = {
"request": {"data": {"region": "66", "area_sqm": 50}},
"extra": {"job": "geocode"},
"level": "error",
}
out = scrub_pii_event(event, {})
assert out["request"]["data"] == {"region": "66", "area_sqm": 50}
assert out["extra"] == {"job": "geocode"}
assert out["level"] == "error"
def test_handles_missing_sections() -> None:
out = scrub_pii_event({}, {})
assert out == {}
def test_handles_none_and_non_dict_sections() -> None:
event = {"request": None, "extra": None, "contexts": "not-a-dict"}
# не должно бросать исключений
out = scrub_pii_event(event, {})
assert out is event
def test_returns_event_not_none() -> None:
"""before_send должен вернуть event (не None) — иначе SDK дропнет отчёт."""
event = {"request": {"data": {"client_name": "X"}}}
out = scrub_pii_event(event, {})
assert out is not None
assert out is event
# ── Telegram bot-token redaction (#tgsupport review, CRITICAL) ──────────────
_LEAKED_TOKEN_URL = "https://api.telegram.org/bot8663867262:AAExampleSecretPartAbCdEf123/getMe"
def test_redacts_token_in_stack_frame_locals() -> None:
"""Вектор #1 (ревьюер): `include_local_variables=True` кладёт locals
`TelegramClient._request` (`self`, `url`) в traceback frame `vars`."""
event = {
"exception": {
"values": [
{
"type": "NetworkError",
"stacktrace": {
"frames": [
{
"function": "_request",
"vars": {
"url": _LEAKED_TOKEN_URL,
"self": {"_base": _LEAKED_TOKEN_URL.rsplit("/", 1)[0]},
"method": "getMe",
},
}
]
},
}
]
}
}
out = redact_telegram_bot_token(event, {})
assert out is not None
frame_vars = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_vars["url"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_vars["self"]["_base"]
assert frame_vars["url"] == "https://api.telegram.org/bot[REDACTED]/getMe"
# Не PII/секрет — остаётся как есть.
assert frame_vars["method"] == "getMe"
def test_redacts_token_in_httpx_span_data() -> None:
"""Вектор #2 (ревьюер): `HttpxIntegration` кладёт полный request URL в span
`data` независимо от traceback — `traces_sample_rate=0.0` спасает сейчас, но
редактор — belt-and-suspenders на случай если трейсинг когда-нибудь включат."""
event = {
"spans": [
{
"op": "http.client",
"description": "POST " + _LEAKED_TOKEN_URL,
"data": {"url": _LEAKED_TOKEN_URL, "http.method": "POST"},
}
]
}
out = redact_telegram_bot_token(event, {})
assert out is not None
span = out["spans"][0]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in span["data"]["url"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in span["description"]
assert span["data"]["http.method"] == "POST"
def test_redacts_token_in_breadcrumb_message() -> None:
"""httpx-логгер (INFO, до нашего getLogger('httpx').setLevel(WARNING) в
tgbot_main) может всплыть breadcrumb'ом с полным URL через LoggingIntegration."""
event = {
"breadcrumbs": {
"values": [
{
"category": "httpx",
"message": f'HTTP Request: POST {_LEAKED_TOKEN_URL} "HTTP/1.1 401"',
}
]
}
}
out = redact_telegram_bot_token(event, {})
assert out is not None
msg = out["breadcrumbs"]["values"][0]["message"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in msg
assert "/bot[REDACTED]/getMe" in msg
def test_redact_token_leaves_unrelated_urls_and_strings_untouched() -> None:
event = {"extra": {"public_url": "https://gendsgn.ru/trade-in", "region": "66"}}
out = redact_telegram_bot_token(event, {})
assert out is not None
assert out["extra"]["public_url"] == "https://gendsgn.ru/trade-in"
assert out["extra"]["region"] == "66"
def test_redact_token_handles_non_dict_event() -> None:
assert redact_telegram_bot_token(None, {}) is None # type: ignore[arg-type]
# Голая форма `<id>:<secret>` без префикса `/bot` — так токен выглядит в локали
# `token` конструктора TelegramClient. Штатно в event не попадает
# (include_local_variables=False в tgbot_main), но редактор обязан покрывать и
# этот случай: иначе рубеж против утечки токена всего один — флаг SDK.
_LEAKED_TOKEN_BARE = "8663867262:AAFakeSecretPartForTestsOnly1234567"
def test_redacts_bare_token_without_bot_prefix() -> None:
event = {
"logentry": {"message": f"init failed for {_LEAKED_TOKEN_BARE}"},
"exception": {
"values": [{"stacktrace": {"frames": [{"vars": {"token": _LEAKED_TOKEN_BARE}}]}}]
},
}
out = redact_telegram_bot_token(event, {})
assert out is not None
assert "AAFakeSecretPartForTestsOnly1234567" not in repr(out)
frame = out["exception"]["values"][0]["stacktrace"]["frames"][0]
assert frame["vars"]["token"] == "[REDACTED]"
@pytest.mark.parametrize(
"benign",
[
"chat_id:12345",
"ratio 3:4",
"2026-07-16T10:00:00",
"postgresql://user:pass@postgres:5432/tradein",
],
)
def test_bare_token_redaction_leaves_benign_colon_strings_untouched(benign: str) -> None:
"""Голая регулярка не должна бить по любым `x:y` — иначе диагностика ослепнет."""
out = redact_telegram_bot_token({"logentry": {"message": benign}}, {})
assert out is not None
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) применяются оба, не заменяя
друг друга — разные классы секретов, разные механизмы обнаружения."""
event = {
"request": {"data": {"client_phone": "+79991234567"}},
"exception": {
"values": [
{
"stacktrace": {
"frames": [{"vars": {"url": _LEAKED_TOKEN_URL}}],
}
}
]
},
}
def composed_before_send(evt, hint):
scrubbed = scrub_pii_event(evt, 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 out["request"]["data"]["client_phone"] == "[REDACTED]"
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
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
# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в
# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в
# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина
# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере).
# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and-
# suspenders слой для retry-кода БЕЗ reraise=True (напр. scraper_kit —
# geocoder.py `reraise=True` устраняет RetryError на своём пути, но остаётся
# фолбэком общего назначения), и по контракту before_send: 401-класс (RetryError)
# схлопывается ПО ИСТОЧНИКУ (не глобально — review round 2 claim #2: разные
# подсистемы с совпавшим типом причины НЕ сливаются), содержательные категории
# (500-подобный generic Exception, OperationalError) проходят НЕТРОНУТЫМИ.
from tenacity import RetryError # noqa: E402
def _hint_for(exc: BaseException) -> dict:
"""Строит hint в форме, которую sentry_sdk реально передаёт в before_send —
`exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint
полагается именно на эту форму, не на уже сериализованный event dict)."""
return {"exc_info": (type(exc), exc, exc.__traceback__)}
def _raise_retry_error_from(cause: BaseException) -> RetryError:
try:
raise cause
except type(cause) as caught:
try:
raise RetryError(None) from caught
except RetryError as retry_exc:
return retry_exc
def test_stabilize_retry_error_sets_stable_fingerprint() -> None:
"""RetryError коллапсится в persistent issue по (culprit, имени типа причины) —
НЕ по нестабильному str(RetryError) (repr() Future с memory address). Без
`event["logger"]` (напр. capture_exception без LoggingIntegration) culprit
падает на явный "unknown", а не пропадает из fingerprint молча."""
exc = _raise_retry_error_from(TimeoutError("Nominatim timed out"))
out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "unknown", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None:
"""Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только
culprit (logger-имя модуля) + фиксированное имя типа исключения-причины
(маленький словарь: HTTPStatusError/ConnectTimeout/TimeoutError/...)."""
exc = _raise_retry_error_from(
ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345")
)
out = stabilize_retry_error_fingerprint({}, _hint_for(exc))
assert out is not None
fingerprint_text = " ".join(out["fingerprint"])
assert "95.165.147.218" not in fingerprint_text
assert "12345" not in fingerprint_text
assert out["fingerprint"] == ["retry-exhausted", "unknown", "ValueError"]
def test_stabilize_retry_error_fingerprint_uses_logger_as_culprit() -> None:
"""`event["logger"]` (sentry_sdk LoggingIntegration ставит его = имя модуля,
вызвавшего logger.exception/.error) идёт в fingerprint как culprit — стабильно
per-модуль, не переменные данные запроса."""
exc = _raise_retry_error_from(TimeoutError("timed out"))
out = stabilize_retry_error_fingerprint({"logger": "app.services.geocoder"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "app.services.geocoder", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_does_not_collapse_unrelated_subsystems() -> None:
"""Review round 2 claim #2: RetryError с ОДИНАКОВЫМ типом причины из
НЕСВЯЗАННЫХ подсистем (geocoder vs scraper_kit) НЕ должны схлопнуться в один
issue — разные проблемы не сливаются, даже если типы причины совпали."""
exc_geocoder = _raise_retry_error_from(TimeoutError("nominatim timed out"))
exc_scraper = _raise_retry_error_from(TimeoutError("yandex detail timed out"))
out_geocoder = stabilize_retry_error_fingerprint(
{"logger": "app.services.geocoder"}, _hint_for(exc_geocoder)
)
out_scraper = stabilize_retry_error_fingerprint(
{"logger": "scraper_kit.providers.yandex.detail"}, _hint_for(exc_scraper)
)
assert out_geocoder is not None
assert out_scraper is not None
assert out_geocoder["fingerprint"] != out_scraper["fingerprint"]
class _DecoyRetryError(Exception):
"""Посторонний класс, СЛУЧАЙНО названный так же, как tenacity.RetryError —
но НЕ его подкласс. Строковое сравнение имён (старый баг, review round 2
claim #4) ложно матчило бы такое; isinstance — нет."""
_DecoyRetryError.__name__ = "RetryError" # type: ignore[misc]
def test_stabilize_retry_error_ignores_lookalike_class_by_name() -> None:
"""type(exc).__name__ == "RetryError" НЕ должно быть достаточно — только
реальный tenacity.RetryError (или его подкласс) триггерит fingerprint-хук."""
exc = _DecoyRetryError("unrelated exception, same class __name__ by accident")
event = {"level": "error"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_leaves_operational_error_untouched() -> None:
"""401-аналог задачи: OperationalError — содержательная категория (реальный
сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с
шумом)."""
from sqlalchemy.exc import OperationalError
exc = OperationalError("SELECT 1", {}, Exception("connection refused"))
event = {"level": "error", "message": "db connection failed"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None:
"""500-аналог задачи: обычное необработанное исключение (не RetryError)
проходит без изменений."""
exc = RuntimeError("scraper city-sweep failed")
event = {"level": "error"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_no_exc_info_untouched() -> None:
"""capture_message-based события (нет exc_info) — фильтр не трогает, напр.
scrape_runs.py consecutive-failure алерты (content-ful, должны доходить)."""
event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"}
out = stabilize_retry_error_fingerprint(dict(event), {})
assert out == event
def test_stabilize_retry_error_handles_non_dict_event() -> None:
assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type]
# ── httpx error-message URL query stabilization (glitchtip-noise review round 2,
# claim #1) ────────────────────────────────────────────────────────────────
#
# `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) bakes
# the FULL request URL — including query string — into the exception message.
# Воспроизведено эмпирически (httpx.Response(403, request=...).raise_for_status()):
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'". После `reraise=True` в geocoder.py
# (устраняет RetryError, но НЕ этот текст) именно ЭТА строка становится GlitchTip
# title/value — переменный `q=<адрес>` на каждый вызов воспроизводит тот же
# per-address issue-explosion, который reraise=True должен был устранить, просто
# сменивший класс исключения (RetryError → HTTPStatusError). Тесты бьют по
# `scrub_pii_event` напрямую (композиция, реально применяемая в before_send).
_NOMINATIM_403_TEMPLATE = (
"Client error '403 Forbidden' for url "
"'https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=3'\n"
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403"
)
def _httpx_error_event(message: str) -> dict:
return {"exception": {"values": [{"type": "HTTPStatusError", "value": message}]}}
def test_scrub_pii_event_stabilizes_httpx_error_url_query() -> None:
"""Query string режется целиком из httpx-style 'for url' сообщения — host+path
остаются стабильными для группировки."""
encoded_ekb = "%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3"
event = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query=encoded_ekb))
out = scrub_pii_event(event, {})
assert out is not None
value = out["exception"]["values"][0]["value"]
assert "search?[REDACTED]'" in value
assert "%D0%95" not in value
assert "nominatim.openstreetmap.org/search" in value # host+path сохранены
def test_scrub_pii_event_httpx_url_query_stabilization_collapses_different_addresses() -> None:
"""Два РАЗНЫХ адреса (переменная часть query) после редактора дают
ИДЕНТИЧНЫЙ текст сообщения — GlitchTip group-title больше не плодит issue
на каждый адрес (review round 2 claim #1)."""
event_a = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Ленина+1"))
event_b = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Мира+42%2C+кв.+5"))
out_a = scrub_pii_event(event_a, {})
out_b = scrub_pii_event(event_b, {})
assert out_a is not None
assert out_b is not None
assert out_a["exception"]["values"][0]["value"] == out_b["exception"]["values"][0]["value"]
def test_scrub_pii_event_httpx_url_without_query_untouched() -> None:
"""URL без query string (напр. scraper detail page — переменная часть в
ПУТИ, не в query) остаётся нетронутым — regex матчит только `?...`."""
message = "Client error '404 Not Found' for url 'https://realty.yandex.ru/offer/12345/'"
event = _httpx_error_event(message)
out = scrub_pii_event(event, {})
assert out is not None
assert out["exception"]["values"][0]["value"] == message
def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched() -> None:
"""Regex бьёт только по 'for url \\'...?...\\'' — произвольный текст с `?` и
кавычками не должен ложно матчиться."""
benign = "Вопрос: 'что такое ЖК \"Солнечный\"?' — уточните адрес"
event = {"extra": {"note": benign}}
out = scrub_pii_event(event, {})
assert out is not None
assert out["extra"]["note"] == benign
# ── payments-disabled drop (issue #3471, GlitchTip-группа TRADE-IN-3GG) ─────
# 503 из `payments.py._require_enabled` — штатный ответ выключенного
# kill-switch'а, а не инцидент: sentry_sdk `StarletteIntegration` репортит его
# как error-событие только потому, что 503 попадает в дефолтный диапазон
# `failed_request_status_codes` (5xx), хотя FastAPI обработал исключение
# штатно. 167 событий/2 недели от внутреннего IP смоук-проверки (кнопки оплаты
# во фронте нет) размывали ленту. Фильтр не должен трогать поведение самой
# ручки (503 остаётся) и не должен глотать другие ошибки — включая другие 503.
from fastapi import HTTPException # noqa: E402
def _exc_info_hint(exc: BaseException) -> dict:
"""Та же форма hint, что sentry_sdk реально передаёт в before_send —
`exc_info = (type, value, traceback)` (см. `_hint_for` выше по файлу)."""
return {"exc_info": (type(exc), exc, exc.__traceback__)}
def test_drop_payments_disabled_event_drops_the_503() -> None:
exc = HTTPException(status_code=503, detail="payments are disabled")
event = {"level": "error", "exception": {"values": [{"type": "HTTPException"}]}}
assert drop_payments_disabled_event(event, _exc_info_hint(exc)) is None
def test_drop_payments_disabled_event_leaves_other_errors_untouched() -> None:
"""Обычная ошибка (не платёжный kill-switch) должна долетать до GlitchTip
без изменений — фильтр специфичен по (status_code, detail), а не по 5xx."""
exc = ValueError("boom")
event = {"level": "error"}
out = drop_payments_disabled_event(event, _exc_info_hint(exc))
assert out is event
def test_drop_payments_disabled_event_leaves_other_503s_untouched() -> None:
"""Другой 503 с другим текстом (напр. будущий maintenance-режим другого
роутера) не должен ложно схлопнуться с платёжным kill-switch'ем."""
exc = HTTPException(status_code=503, detail="service temporarily unavailable")
event = {"level": "error"}
out = drop_payments_disabled_event(event, _exc_info_hint(exc))
assert out is event
def test_drop_payments_disabled_event_leaves_matching_detail_wrong_status_untouched() -> None:
"""Тот же текст detail, но другой status_code — не платёжный kill-switch."""
exc = HTTPException(status_code=500, detail="payments are disabled")
event = {"level": "error"}
out = drop_payments_disabled_event(event, _exc_info_hint(exc))
assert out is event
def test_drop_payments_disabled_event_no_exc_info_untouched() -> None:
event = {"level": "error"}
out = drop_payments_disabled_event(event, {})
assert out is event
def test_drop_payments_disabled_event_handles_non_dict_event() -> None:
assert drop_payments_disabled_event(None, {}) is None # type: ignore[arg-type]