All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
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 4m56s
Слияние main принесло собственные Sentry-скрубберы (redact_telegram_bot_token + stabilize_retry_error_fingerprint) в app/main.py и app/scheduler_main.py — конфликт разрешён композицией, а не выбором стороны: обработчик перед отправкой в GlitchTip теперь прогоняет событие через всю цепочку в указанном порядке: scrub_payment_request_body → scrub_pii_event → redact_telegram_bot_token → stabilize_retry_error_fingerprint (main.py), и без redact_telegram_bot_token в scheduler_main.py (тот процесс не держит TelegramClient) — оба канала, before_send и before_send_transaction, используют один и тот же обработчик. tests/test_sentry_scrub.py: тесты обеих сторон объединены без потерь — PR-D2 платёжный composed-тест (body-wipe + PII-scrub + token-redaction) и весь блок RetryError fingerprint-стабилизации из main сосуществуют в одном файле.
629 lines
29 KiB
Python
629 lines
29 KiB
Python
"""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 (
|
||
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
|