gendesign/tradein-mvp/backend/tests/test_sentry_scrub.py
bot-backend 1f85ef7d4e
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Successful in 3m50s
CI Trade-In / changes (pull_request) Successful in 8s
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
fix(tradein/payments): тело нотификации не течёт в мониторинг и аудит, повторы банка не отбиваются лимитом
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей
(PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile,
roles.yaml, auth_session.py не тронуты.

- sentry_scrub.py: новая scrub_payment_request_body — вырезает
  event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64
  кладёт полное тело запроса в request.data, send_default_pii=False это НЕ
  гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS:
  customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey.
- main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации
  sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала,
  before_send и before_send_transaction. Мотивирующий инцидент: на соседнем
  продукте вчера закрыли только error-канал, transaction остался без
  обработчика вообще.
- ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter
  (3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное
  отключение — backstop против шторма запросов остаётся, подпись проверяется
  уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией).
- request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware
  внешний относительно rbac_guard и читает сырой X-Authenticated-User —
  спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin).
- smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout
  сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401
  (gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра.

Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор
(не substring — комментарии в этих же файлах сами упоминают
before_send_transaction) на проводку обоих каналов во всех точках
инициализации, 400 запросов notify без единого 429 + контроль что общий
лимитер по-прежнему активен на других путях, notify вне user_events даже со
спуфнутым X-Authenticated-User: admin.
2026-08-07 16:17:59 +03:00

415 lines
17 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 (
redact_telegram_bot_token,
scrub_payment_request_body,
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]
# Голая форма `<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