"""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_pii_event, ) 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 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