gendesign/tradein-mvp/backend/tests/test_glitchtip_webhook.py
bot-backend ed94a03f73
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 14s
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
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
fix(tradein): не логировать секрет вебхука GlitchTip в access-log (#3154)
uvicorn печатает в access-log полный путь вместе с query, поэтому секрет
вебхука (`?secret=`, он же TRADEIN_INTERNAL_AUTH_SECRET, второй рубеж rbac)
уезжал в Loki открытым текстом. Скруббер #3115 в Alloy ловит только форму
`user:pass@host` (DSN postgres_exporter) и такую строку не закрывает.

Два слоя:
- хендлер принимает секрет из заголовка X-GlitchTip-Secret; query-параметр
  остаётся fallback'ом, т.к. сам GlitchTip 6.1.6 (`send_webhook()`)
  заголовков не шлёт вовсе — убрать query можно только когда заголовок начнёт
  подставлять кто-то перед нами (Caddy header_up) или сменится отправитель;
- app/core/log_scrub.py: logging-фильтр маскирует значения чувствительных
  query-параметров (secret/token/api_key/…) на uvicorn.access и на
  обработчиках корневого логгера — секрета нет уже в `docker logs`.

Сравнение секрета и было constant-time (`secrets.compare_digest`).
2026-09-05 22:51:57 +05:00

318 lines
12 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.

"""Offline-тесты приёмника GlitchTip webhook-алертов
(POST /api/v1/trade-in/ops/glitchtip-webhook) — app/api/v1/glitchtip.py.
Проверяет HTTP-контракт: успешная пересылка issue-/uptime-алертов в
Telegram (клиент замокан), отказ без валидного секрета, отказ при
несконфигурированных настройках, обрезка длинного текста под лимит Telegram
(4096), graceful-обработка неизвестной формы payload (НЕ 500).
NEVER touches real DB / real Telegram API.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import json
from typing import Any, ClassVar
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import glitchtip as glitchtip_module
from app.services.tgbot.client import TelegramApiError
_SECRET = "test-shared-secret"
_ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook"
@pytest.fixture(autouse=True)
def _configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""По умолчанию вебхук полностью сконфигурирован — отдельные тесты
переопределяют конкретные поля."""
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", _SECRET)
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "fake-token")
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", -1004443088679)
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_topic_id", 158)
class _FakeTelegramClient:
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети."""
calls: ClassVar[list[dict[str, Any]]] = []
_response: ClassVar[dict[str, Any] | Exception] = {"message_id": 1}
def __init__(self, _token: str) -> None:
pass
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
_FakeTelegramClient.calls.append(kwargs)
if isinstance(_FakeTelegramClient._response, Exception):
raise _FakeTelegramClient._response
return _FakeTelegramClient._response
@pytest.fixture(autouse=True)
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
_FakeTelegramClient.calls = []
_FakeTelegramClient._response = {"message_id": 1}
monkeypatch.setattr(glitchtip_module, "TelegramClient", _FakeTelegramClient)
return _FakeTelegramClient
@pytest.fixture
def client() -> TestClient:
app = FastAPI()
app.include_router(glitchtip_module.router, prefix="/api/v1/trade-in")
return TestClient(app)
_ISSUE_PAYLOAD = {
"text": "GlitchTip Alert",
"attachments": [
{
"title": "ValueError: something broke",
"title_link": "https://errors.gendsgn.ru/organizations/gendesign/issues/123/",
"text": "app/services/foo.py in bar",
"color": "#e03131",
"fields": [
{"title": "Project", "value": "tradein-backend", "short": True},
{"title": "Environment", "value": "production", "short": True},
],
"mrkdown_in": ["text"],
}
],
}
_UPTIME_PAYLOAD = {
"text": "GlitchTip Uptime Alert",
"attachments": [
{
"title": "gendsgn.ru",
"title_link": "https://errors.gendsgn.ru/organizations/gendesign/uptime/1/",
"text": "The monitored site has gone down.",
"image_url": None,
"color": None,
"fields": None,
"mrkdown_in": None,
}
],
}
# ── успешная пересылка ──────────────────────────────────────────────────────
def test_issue_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 200, r.text
assert r.json() == {"status": "ok"}
assert len(_fake_telegram_client.calls) == 1
call = _fake_telegram_client.calls[0]
assert call["chat_id"] == -1004443088679
assert call["message_thread_id"] == 158
assert "ValueError: something broke" in call["text"]
assert "tradein-backend" in call["text"] # Project field
assert "errors.gendsgn.ru" in call["text"]
def test_uptime_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_UPTIME_PAYLOAD)
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
call = _fake_telegram_client.calls[0]
assert "GlitchTip Uptime Alert" in call["text"]
assert "gone down" in call["text"]
assert call["message_thread_id"] == 158
def test_telegram_failure_returns_502_not_500(
client: TestClient, _fake_telegram_client: Any
) -> None:
_FakeTelegramClient._response = TelegramApiError("sendMessage", 400, "chat not found")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 502
assert r.status_code != 500
# ── auth ─────────────────────────────────────────────────────────────────────
def test_missing_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD)
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_wrong_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret=wrong-value", json=_ISSUE_PAYLOAD)
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_secret_accepted_from_header_without_query(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""#3154: секрет можно прислать заголовком — тогда он не течёт в access-log."""
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD, headers={"X-GlitchTip-Secret": _SECRET})
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_wrong_header_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD, headers={"X-GlitchTip-Secret": "wrong-value"})
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_query_secret_still_accepted_as_fallback(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""GlitchTip 6.1.6 заголовков не шлёт вовсе — query-путь обязан работать."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_secret_not_configured_returns_503_not_500(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", "")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert r.status_code != 500
assert _fake_telegram_client.calls == []
def test_bot_not_configured_returns_503(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert _fake_telegram_client.calls == []
def test_alerts_chat_id_not_configured_returns_503(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", 0)
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert _fake_telegram_client.calls == []
# ── обрезка длинного текста ─────────────────────────────────────────────────
def test_long_payload_truncated_to_telegram_limit(
client: TestClient, _fake_telegram_client: Any
) -> None:
huge_payload = {
"text": "GlitchTip Alert",
"attachments": [
{
"title": "Huge issue",
"title_link": "https://errors.gendsgn.ru/x",
"text": "x" * 10000,
}
],
}
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=huge_payload)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert len(sent_text) <= 4096
assert sent_text.endswith("(обрезано)")
def test_unknown_form_huge_raw_body_truncated(
client: TestClient, _fake_telegram_client: Any
) -> None:
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=("x" * 10000).encode(),
headers={"content-type": "application/json"},
)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert len(sent_text) <= 4096
# ── неизвестная форма payload — НЕ 500 ──────────────────────────────────────
def test_unknown_json_shape_forwarded_with_marker(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""Ни `text`, ни `attachments` — форма, которую GlitchTip НЕ шлёт сегодня,
но контракт задачи требует не падать 500, а переслать как есть."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json={"some_field": "some_value", "n": 42})
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert "неизвестный формат" in sent_text
assert "some_value" in sent_text
def test_non_json_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=b"not-json-at-all {{{",
headers={"content-type": "text/plain"},
)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert "неизвестный формат" in sent_text
assert "not-json-at-all" in sent_text
def test_json_array_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
"""Валидный JSON, но не объект (top-level list) — тоже неизвестная форма."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=[1, 2, 3])
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_empty_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", content=b"")
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
# ── формат сообщения ─────────────────────────────────────────────────────────
def test_message_format_json_roundtrip(client: TestClient, _fake_telegram_client: Any) -> None:
"""Sanity: убеждаемся, что тестовый payload реально валиден как JSON (не
полагаемся на literal dict без проверки сериализации)."""
body = json.dumps(_ISSUE_PAYLOAD)
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=body.encode(),
headers={"content-type": "application/json"},
)
assert r.status_code == 200, r.text