gendesign/tradein-mvp/backend/tests/test_glitchtip_webhook.py
bot-backend b649caec2d
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
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 4m50s
feat(tradein/ops): приёмник GlitchTip webhook-алертов в Telegram-тему
Мониторинг GlitchTip сейчас нем (alerts_projectalert/alerts_alertrecipient
пусты, EMAIL_URL=consolemail:// печатает письма в stdout, аудит на проде
2026-08-15). GlitchTip умеет получателя типа webhook, но шлёт свой Slack-
совместимый JSON без каких-либо заголовков — Telegram Bot API его не
понимает, нужен адаптер.

- app/api/v1/glitchtip.py: POST /api/v1/trade-in/ops/glitchtip-webhook —
  принимает issue- и uptime-алерты (структурно одинаковый payload у
  GlitchTip 6.1.6, см. docstring), форматирует короткое сообщение
  (проект/заголовок/ссылка/время получения) и шлёт через существующий
  TelegramClient в отдельную тему алертов. Обрезка под лимит Telegram
  (4096 симв.), неизвестная форма payload пересылается как есть с
  пометкой вместо 500.
- Auth: GlitchTip не может слать кастомные заголовки (aiohttp.post без
  headers=) — переиспользуем TRADEIN_INTERNAL_AUTH_SECRET (#2213) как
  query-параметр `secret`, constant-time compare. В отличие от rbac.py
  пустой секрет здесь fail-CLOSED (503), это единственный auth-рубеж пути.
- config.py: TELEGRAM_ALERTS_CHAT_ID / TELEGRAM_ALERTS_TOPIC_ID — намеренно
  отдельные от TELEGRAM_SUPPORT_*, чтобы алерты не лились в топик клиентов.
- rbac.py: путь добавлен в _PUBLIC_PATHS (фиксированный, без секрета в
  самом пути — секрет только в query).
- docker-compose.prod.yml: glitchtip-worker (реально шлёт вебхуки, не
  glitchtip-web) переведён на networks: [default, shared] — без этого
  tradein-backend не резолвится с его стороны (общей сети не было вообще).
2026-08-15 22:39:44 +03:00

291 lines
11 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_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