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
Клиент пишет боту в личку → воркер зеркалит сообщение через 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).
261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""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
|