"""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] # Голая форма `:` без префикса `/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, #) ─ # # 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