gendesign/tradein-mvp/backend/tests/test_sentry_scrub.py
bot-backend b579fa4ced
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 5m4s
feat(tradein/tgbot): Telegram support-мост @MERAsupport_bot
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage
в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот
доставляет ответ клиенту. Полный лог переписки в Postgres.

Отдельный контейнер на long-polling, а не webhook в tradein-backend:
не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и
маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API.
Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage.

Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален
в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате,
поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора
не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор.

Безопасность (найдено ревью, воспроизведено эмпирически):
- токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет;
  утекал в GlitchTip через locals стек-фреймов (include_local_variables
  по умолчанию True) и через span data HttpxIntegration. Закрыто
  include_local_variables=False + regex-редактор в before_send (обе формы:
  /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub.
- httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в
  docker logs каждые 30с. Приглушён до WARNING.

Надёжность:
- kill-switch при пустом токене — idle-блокировка, не exit(0): при
  restart: unless-stopped выход с любым кодом даёт рестарт-луп.
  unless-stopped выбран сознательно — только он гарантирует автозапуск
  после ребута VPS.
- stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше,
  чем докрутится long-poll (30с) и отработает drain (100с).
- сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset:
  иначе сессия в failed-transaction не давала сохранить offset, апдейт
  переигрывался и зеркалился в топик по кругу.

152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента
одним DELETE. Ретенция — follow-up.

Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS,
без них воркер штатно висит в idle. Порядок — в DEPLOY.md.

Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked,
throttle-окно шапки, redaction токена во всех формах event).
2026-07-16 16:58:53 +03:00

261 lines
10 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_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
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