"""Unit-тесты логики инициализации GlitchTip SDK. Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init() только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/ company/message) из request.data/extra/contexts (#2457-review), и — в конце файла — что до транспорта не доезжают ни ПДн тела запроса, ни значения локальных переменных кадра стека, ни второе событие о сбое самого скраба (#2753, поведение через подставной транспорт вместо поиска подстроки). """ import json import os import pathlib import subprocess import sys from functools import lru_cache from unittest.mock import patch import pytest import sentry_sdk _BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1] def test_sdk_imports_without_error() -> None: """Все интеграции импортируются без ModuleNotFoundError.""" from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.httpx import HttpxIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration assert StarletteIntegration() assert FastApiIntegration() assert CeleryIntegration() assert SqlalchemyIntegration() assert HttpxIntegration() assert LoggingIntegration() def test_no_sdk_init_when_dsn_empty() -> None: """Если GLITCHTIP_DSN пустой, sentry_sdk.init() не должен вызываться.""" with patch("sentry_sdk.init") as mock_init: glitchtip_dsn = None if glitchtip_dsn: sentry_sdk.init(dsn=glitchtip_dsn) mock_init.assert_not_called() def test_sdk_init_called_when_dsn_set() -> None: """Если GLITCHTIP_DSN задан, sentry_sdk.init() вызывается с правильными параметрами.""" dsn = "https://key@errors.gendsgn.ru/1" with patch("sentry_sdk.init") as mock_init: glitchtip_dsn = dsn if glitchtip_dsn: sentry_sdk.init( dsn=glitchtip_dsn, environment="test", release="unknown", traces_sample_rate=0.05, profiles_sample_rate=0.0, send_default_pii=False, integrations=[], ) mock_init.assert_called_once() call_kwargs = mock_init.call_args.kwargs assert call_kwargs["dsn"] == dsn assert call_kwargs["send_default_pii"] is False assert call_kwargs["profiles_sample_rate"] == 0.0 # ── release fallback ────────────────────────────────────────────────────────── def test_release_uses_git_sha_when_set() -> None: """GIT_SHA имеет приоритет над SENTRY_RELEASE.""" env = {"GIT_SHA": "abc1234", "SENTRY_RELEASE": "v1.0.0"} with patch.dict(os.environ, env, clear=False): release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" assert release == "abc1234" def test_release_falls_back_to_sentry_release() -> None: """Если GIT_SHA не задан, используется SENTRY_RELEASE.""" env = {"SENTRY_RELEASE": "v1.2.3"} with patch.dict(os.environ, env, clear=False): # Убираем GIT_SHA если он есть os.environ.pop("GIT_SHA", None) release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" assert release == "v1.2.3" def test_release_falls_back_to_unknown() -> None: """Если ни одна переменная не задана, release='unknown'.""" with patch.dict(os.environ, {}, clear=False): os.environ.pop("GIT_SHA", None) os.environ.pop("SENTRY_RELEASE", None) release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" assert release == "unknown" # ── sentry_scrub ────────────────────────────────────────────────────────────── def test_scrub_redacts_apikey_in_span_url() -> None: """scrub_sensitive_query заменяет apiKey= в span data['url'].""" from app.observability.sentry_scrub import scrub_sensitive_query event: dict = { "spans": [ { "data": { "url": "https://api.objctv.ru/v2/Report?apiKey=supersecret&group=EKB", "http.url": "https://api.objctv.ru/v2/Report?api_key=topsecret", } } ] } result = scrub_sensitive_query(event, {}) span_data = result["spans"][0]["data"] assert "[REDACTED]" in span_data["url"] assert "supersecret" not in span_data["url"] assert "[REDACTED]" in span_data["http.url"] assert "topsecret" not in span_data["http.url"] def test_scrub_redacts_token_in_description() -> None: """scrub_sensitive_query заменяет token= в span description.""" from app.observability.sentry_scrub import scrub_sensitive_query event: dict = { "spans": [{"description": "GET https://example.com?token=mysecrettoken&foo=bar"}] } result = scrub_sensitive_query(event, {}) assert "[REDACTED]" in result["spans"][0]["description"] assert "mysecrettoken" not in result["spans"][0]["description"] def test_scrub_redacts_request_url() -> None: """scrub_sensitive_query заменяет token в event['request']['url'].""" from app.observability.sentry_scrub import scrub_sensitive_query event: dict = {"request": {"url": "https://example.com?access_token=abc123&other=val"}} result = scrub_sensitive_query(event, {}) assert "[REDACTED]" in result["request"]["url"] assert "abc123" not in result["request"]["url"] def test_scrub_passes_through_clean_event() -> None: """scrub_sensitive_query не трогает URL без чувствительных параметров.""" from app.observability.sentry_scrub import scrub_sensitive_query event: dict = { "spans": [{"data": {"url": "https://example.com?foo=bar&page=1"}}], "request": {"url": "https://example.com/api/health"}, } result = scrub_sensitive_query(event, {}) assert result["spans"][0]["data"]["url"] == "https://example.com?foo=bar&page=1" assert result["request"]["url"] == "https://example.com/api/health" def test_scrub_handles_missing_spans() -> None: """scrub_sensitive_query не падает если 'spans' отсутствует.""" from app.observability.sentry_scrub import scrub_sensitive_query event: dict = {"request": {"url": "https://example.com"}} result = scrub_sensitive_query(event, {}) assert result["request"]["url"] == "https://example.com" # ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ──────────── # # send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий # в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data, # а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk # 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором. def test_scrub_pii_redacts_request_data() -> None: """scrub_pii_event заменяет client_name/phone/email в request.data.""" from app.observability.sentry_scrub import scrub_pii_event event: dict = { "request": { "data": { "client_name": "Иван Иванов", "phone": "+79991234567", "email": "ivan@example.com", "address": "Екатеринбург, ул. Ленина 1", } } } result = scrub_pii_event(event, {}) data = result["request"]["data"] assert data["client_name"] == "[REDACTED]" assert data["phone"] == "[REDACTED]" assert data["email"] == "[REDACTED]" # non-PII поле не трогаем assert data["address"] == "Екатеринбург, ул. Ленина 1" def test_scrub_pii_redacts_pilot_request_company_and_message() -> None: """scrub_pii_event заменяет company/message — свободный текст PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают телефоны/имена/адреса, а не только фиксированные name/phone/email (#2457-review).""" from app.observability.sentry_scrub import scrub_pii_event event: dict = { "request": { "data": { "company": "ООО Ромашка", "message": "Меня зовут Иван, звоните на +79991234567", "source": "landing", } } } result = scrub_pii_event(event, {}) data = result["request"]["data"] assert data["company"] == "[REDACTED]" assert data["message"] == "[REDACTED]" # non-PII поле не трогаем assert data["source"] == "landing" def test_scrub_pii_redacts_client_prefixed_keys() -> None: """Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) — Птица их сегодня не использует, но одинаковый механизм на оба продукта проще сопровождать (#2457-review).""" from app.observability.sentry_scrub import scrub_pii_event event: dict = { "extra": { "client_name": "Иван", "client_phone": "+79991234567", "client_email": "ivan@example.com", } } result = scrub_pii_event(event, {}) extra = result["extra"] assert extra["client_name"] == "[REDACTED]" assert extra["client_phone"] == "[REDACTED]" assert extra["client_email"] == "[REDACTED]" def test_scrub_pii_redacts_extra() -> None: """scrub_pii_event заменяет PII-ключи в extra, не трогая остальное.""" from app.observability.sentry_scrub import scrub_pii_event event: dict = { "extra": { "phone": "+79990000000", "email": "x@y.ru", "name": "Пётр", "lead_id": 42, } } result = scrub_pii_event(event, {}) extra = result["extra"] assert extra["phone"] == "[REDACTED]" assert extra["email"] == "[REDACTED]" assert extra["name"] == "[REDACTED]" assert extra["lead_id"] == 42 def test_scrub_pii_is_case_insensitive() -> None: """Сверка PII-ключей case-insensitive.""" from app.observability.sentry_scrub import scrub_pii_event event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}} result = scrub_pii_event(event, {}) assert result["extra"]["Client_Name"] == "[REDACTED]" assert result["extra"]["PHONE"] == "[REDACTED]" def test_scrub_pii_redacts_nested_contexts() -> None: """scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII.""" from app.observability.sentry_scrub import scrub_pii_event event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}} result = scrub_pii_event(event, {}) assert result["contexts"]["lead"]["email"] == "[REDACTED]" assert result["contexts"]["trace"]["op"] == "http" def test_scrub_pii_leaves_non_pii_untouched() -> None: """scrub_pii_event не трогает event без PII-ключей.""" from app.observability.sentry_scrub import scrub_pii_event event: dict = { "request": {"data": {"region": "66", "area_sqm": 50}}, "extra": {"job": "geocode"}, "level": "error", } result = scrub_pii_event(event, {}) assert result["request"]["data"] == {"region": "66", "area_sqm": 50} assert result["extra"] == {"job": "geocode"} assert result["level"] == "error" def test_scrub_pii_handles_missing_sections() -> None: """scrub_pii_event не падает на пустом event.""" from app.observability.sentry_scrub import scrub_pii_event result = scrub_pii_event({}, {}) assert result == {} def test_scrub_pii_handles_none_and_non_dict_sections() -> None: """scrub_pii_event не бросает исключений на None/не-dict секциях.""" from app.observability.sentry_scrub import scrub_pii_event event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"} result = scrub_pii_event(event, {}) assert result is event def test_scrub_pii_returns_event_not_none() -> None: """before_send должен вернуть event (не None) — иначе SDK дропнет отчёт.""" from app.observability.sentry_scrub import scrub_pii_event event: dict = {"request": {"data": {"client_name": "X"}}} result = scrub_pii_event(event, {}) assert result is not None assert result is event # ── scrub_event (composed before_send / before_send_transaction handler) ─────── # # scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА # канала (before_send И before_send_transaction). До #2457-review composed-хук # висел только на before_send, а before_send_transaction оставался на голом # scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data # на transaction-scope так же, как на error-scope, поэтому PII продолжало течь # через transaction-канал при glitchtip_traces_sample_rate > 0. def test_scrub_event_composes_pii_and_url_secret_scrub() -> None: """scrub_event применяет PII-scrub (ключ-based) И URL query-string secret redact (regex) оба разом, не заменяя друг друга — разные классы данных.""" from app.observability.sentry_scrub import scrub_event event: dict = { "request": { "data": {"client_name": "Иван"}, "url": "https://example.com?api_key=supersecret", } } result = scrub_event(event, {}) assert result is not None assert result["request"]["data"]["client_name"] == "[REDACTED]" assert "[REDACTED]" in result["request"]["url"] assert "supersecret" not in result["request"]["url"] def test_scrub_event_returns_event_not_none() -> None: """scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт.""" from app.observability.sentry_scrub import scrub_event event: dict = {"request": {"data": {"name": "X"}}} result = scrub_event(event, {}) assert result is not None assert result is event def test_scrub_event_survives_scrub_pii_event_exception() -> None: """try/except в scrub_event — предохранитель: sentry_sdk оборачивает before_send в capture_internal_exceptions, который при исключении ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал). Если scrub_pii_event падает — scrub_event обязан вернуть event, а не пробросить исключение дальше (#2457-review).""" from app.observability.sentry_scrub import scrub_event event: dict = {"request": {"data": {"client_name": "X"}}} with patch( "app.observability.sentry_scrub.scrub_pii_event", side_effect=RuntimeError("boom"), ): result = scrub_event(event, {}) assert result is not None assert result is event def test_scrub_event_survives_scrub_sensitive_query_exception() -> None: """То же самое для второго шага композиции (URL-secret redact).""" from app.observability.sentry_scrub import scrub_event event: dict = {"request": {"data": {"name": "X"}}} with patch( "app.observability.sentry_scrub.scrub_sensitive_query", side_effect=RuntimeError("boom"), ): result = scrub_event(event, {}) assert result is not None assert result is event # ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ───── # # До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в # файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут # многострочные комментарии, где те же подстроки встречаются, — достаточно # удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не # говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом # before_send (локальные переменные кадра стека уходят мимо ключевого скраба). # # Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе # (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него # доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а # `capture_envelope` подменён до первого события. @lru_cache(maxsize=2) def _probe(module: str) -> str: """Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку.""" env = { **os.environ, "TESTING": "1", # Синтаксически валидный DSN на несуществующий хост: init отработает, # сети не будет даже если транспорт когда-нибудь перестанут подменять. "GLITCHTIP_DSN": "https://probe@localhost.invalid/1", # Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/), # и без этого `import app` уехал бы в editable-установку пакета — то есть # проба мерила бы чужое дерево, а не то, что рядом с ней лежит. "PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]), } proc = subprocess.run( [sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module], cwd=_BACKEND_ROOT, env=env, capture_output=True, text=True, timeout=300, check=False, ) assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}" return proc.stdout.strip().splitlines()[-1] @pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) def test_pii_never_reaches_transport(module: str) -> None: """Оба канала (error И transaction) отдают транспорту событие без ПДн. Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ `before_send_transaction` — комментарий с теми же словами не спасает. """ probe = json.loads(_probe(module)) markers = probe["markers"] for channel in ("error", "transaction"): payload = probe["payloads"][channel] # Контроль «событие вообще доехало»: без него проверка была бы зелёной # и на пробе, которая молча ничего не отправила. assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало" assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал" leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload] assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}" @pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) def test_local_variables_never_reach_transport(module: str) -> None: """`include_local_variables=False`: значения локальных переменных кадра стека не уходят в мониторинг (#2753). Ключевой скраб такое не ловит по построению — имя переменной произвольно, а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()` (в sentry-sdk он по умолчанию `True`). """ probe = json.loads(_probe(module)) payload = probe["payloads"]["exception"] assert probe["counts"]["exception"] == 1 assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало" assert ( probe["markers"]["local_var"] not in payload ), f"{module}: значение локальной переменной ушло в мониторинг" @pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) def test_scrub_failure_does_not_spawn_second_event(module: str) -> None: """Сбой самого скраба не порождает ВТОРОГО события (#2753). `logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR), снова попала бы в скраб, снова упала — рекурсия, защиты от которой в SDK нет (проверено на коде до фикса: не завершается). Красный, если `ignore_logger` убрать: обработчик войдёт повторно. """ probe = json.loads(_probe(module)) assert ( probe["scrub_handler_entries"] == 1 ), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг" assert probe["counts"]["scrub_failure"] == 1