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
Мониторинг 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 не резолвится с его стороны (общей сети не было вообще).
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""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
|