feat(tradein/ops): алерты GlitchTip доезжают в Telegram #2915
6 changed files with 537 additions and 1 deletions
|
|
@ -367,7 +367,16 @@ services:
|
|||
CELERY_WORKER_AUTOSCALE: "1,3"
|
||||
restart: always
|
||||
mem_limit: 384m
|
||||
networks: [default]
|
||||
# GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15,
|
||||
# см. tradein-mvp/backend/app/api/v1/glitchtip.py): вебхуки шлёт РЕАЛЬНО
|
||||
# этот celery-воркер (apps/alerts/webhooks.py send_webhook — не glitchtip-web),
|
||||
# получателю `webhook` нужен доступ к http://tradein-backend:8000/... —
|
||||
# tradein-backend сидит на gendesign_shared, у glitchtip-* её раньше не было
|
||||
# вообще (та же грабля, что #2709 у redis: сеть должна быть общей ДО того,
|
||||
# как переменная окружения с URL вообще имеет смысл). default — обязательно
|
||||
# явно, иначе воркер потеряет Postgres/Redis-брокер (см. комментарий у redis
|
||||
# выше про неявную привязку к default).
|
||||
networks: [default, shared]
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
|
|
|
|||
219
tradein-mvp/backend/app/api/v1/glitchtip.py
Normal file
219
tradein-mvp/backend/app/api/v1/glitchtip.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""GlitchTip → Telegram алерты (мониторинг сейчас нем: `alerts_projectalert`/
|
||||
`alerts_alertrecipient` пусты, `EMAIL_URL=consolemail://` печатает письма в
|
||||
stdout и никуда их не доставляет — аудит на проде 2026-08-15).
|
||||
|
||||
GlitchTip (self-hosted, `errors.gendsgn.ru`, образ `glitchtip/glitchtip:6.1.6`)
|
||||
умеет слать получателю типа `webhook` (``RecipientType.GENERAL_WEBHOOK`` —
|
||||
"General Slack-compatible webhook"). И issue-алерты (``apps/alerts/webhooks.py
|
||||
send_issue_as_webhook``), и uptime-алерты (``apps/uptime/webhooks.py
|
||||
_send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ низкоуровневую
|
||||
``send_webhook()`` — ``aiohttp.ClientSession.post(url, json=asdict(WebhookPayload
|
||||
(text=..., attachments=[...])))``, БЕЗ каких-либо заголовков (ни Authorization,
|
||||
ни подписи, ни X-*). Значит:
|
||||
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
|
||||
``{"text": str, "attachments": [{"title","title_link","text","color",
|
||||
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
|
||||
2) единственный канал для аутентификации — сам URL (как и Slack-вебхуки).
|
||||
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
|
||||
нечем (GlitchTip-сторона его не добавляет).
|
||||
|
||||
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
|
||||
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
|
||||
``secrets.compare_digest`` constant-time compare, тот же env. Отличие от
|
||||
rbac-паттерна: ТАМ пустой секрет — fail-open (есть второй рубеж, roles.yaml).
|
||||
ЗДЕСЬ секрет — единственный рубеж вообще, поэтому пустой секрет ИЛИ
|
||||
несконфигурированный Telegram-бот → 503 "не настроено", а не тихий
|
||||
fail-open настежь.
|
||||
|
||||
Путь ФИКСИРОВАННЫЙ (не несёт секрет в себе) — так его можно добавить в
|
||||
``app.core.rbac._PUBLIC_PATHS`` одной строкой (точное совпадение, без
|
||||
regex/prefix-веток в ``rbac_guard``). Сам путь — не секрет, секрет — только
|
||||
значение query-параметра.
|
||||
|
||||
Сетевая связность (docker-compose.prod.yml, корневой стек): вебхуки шлёт
|
||||
``glitchtip-worker`` (celery-таска), НЕ ``glitchtip-web`` — оба сейчас сидят
|
||||
только в ``gendesign_default``. tradein-backend слушает на ``gendesign_shared``
|
||||
(алиас неявный — Docker embedded DNS резолвит по ``container_name``, тот же
|
||||
приём уже используется Caddy → ``tradein-backend:8000``, см. Caddyfile).
|
||||
Значит ``glitchtip-worker`` тоже должен быть подписан на ``gendesign_shared``,
|
||||
иначе имя ``tradein-backend`` не резолвится — общей сети нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Telegram sendMessage лимит — 4096 символов (см. support.py MAX_MESSAGE_LENGTH
|
||||
# для исходящих сообщений пользователя; здесь лимит на ИСХОДЯЩЕЕ в Telegram, тот
|
||||
# же потолок). Суффикс обрезки учтён в _truncate.
|
||||
_TELEGRAM_MAX_LEN = 4096
|
||||
_TRUNCATE_SUFFIX = "\n… (обрезано)"
|
||||
|
||||
# Узкий интерактивный бюджет (тот же принцип, что #tgsupport-web review H1 в
|
||||
# support.py): GlitchTip-таска ждёт HTTP-ответ синхронно (её собственный aiohttp
|
||||
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
||||
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
||||
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||
|
||||
|
||||
class GlitchTipAttachment(BaseModel):
|
||||
"""Slack-совместимый attachment. Issue- и uptime-алерты заполняют РАЗНЫЕ
|
||||
подмножества полей (uptime не шлёт ``fields``/``color``) — все опциональны,
|
||||
``extra="allow"`` на случай будущих версий GlitchTip."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
title: str | None = None
|
||||
title_link: str | None = None
|
||||
text: str | None = None
|
||||
color: str | None = None
|
||||
fields: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class GlitchTipWebhookPayload(BaseModel):
|
||||
"""Тело POST от GlitchTip ``send_webhook()`` — одинаковое для issue- и
|
||||
uptime-алертов (см. docstring модуля)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
text: str | None = None
|
||||
attachments: list[GlitchTipAttachment] | None = None
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = _TELEGRAM_MAX_LEN) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - len(_TRUNCATE_SUFFIX)] + _TRUNCATE_SUFFIX
|
||||
|
||||
|
||||
def _field_value(attachment: GlitchTipAttachment, label: str) -> str | None:
|
||||
"""Ищет значение поля attachment.fields по title (issue-алерты кладут туда
|
||||
"Project" литералом — см. apps/alerts/webhooks.py send_issue_as_webhook)."""
|
||||
for field in attachment.fields or []:
|
||||
if str(field.get("title", "")).strip().lower() == label.lower():
|
||||
value = field.get("value")
|
||||
return str(value) if value is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def _format_known_payload(payload: GlitchTipWebhookPayload, received_at: datetime) -> str:
|
||||
lines = [f"GlitchTip: {payload.text or 'Alert'}"]
|
||||
for attachment in payload.attachments or []:
|
||||
block: list[str] = []
|
||||
project = _field_value(attachment, "Project")
|
||||
if project:
|
||||
block.append(f"Проект: {project}")
|
||||
if attachment.title:
|
||||
block.append(attachment.title)
|
||||
if attachment.text:
|
||||
block.append(attachment.text)
|
||||
if attachment.title_link:
|
||||
block.append(f"Ссылка: {attachment.title_link}")
|
||||
if block:
|
||||
lines.append("")
|
||||
lines.extend(block)
|
||||
lines.append("")
|
||||
lines.append(f"Получено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_unknown_payload(raw_body: bytes, received_at: datetime) -> str:
|
||||
"""Payload не распознан ни как issue-, ни как uptime-алерт (нет ни `text`,
|
||||
ни `attachments`, либо тело — не JSON-объект вовсе) — не роняем запрос,
|
||||
пересылаем как есть с пометкой (см. требование задачи: неизвестная форма
|
||||
payload не должна давать 500)."""
|
||||
text_repr = raw_body.decode("utf-8", errors="replace")
|
||||
header = "GlitchTip webhook: неизвестный формат payload, пересылаю как есть"
|
||||
return _truncate(
|
||||
f"{header}\n\n{text_repr}\n\nПолучено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"
|
||||
)
|
||||
|
||||
|
||||
def _build_message(raw_body: bytes, received_at: datetime) -> str:
|
||||
try:
|
||||
data = json.loads(raw_body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
try:
|
||||
payload = GlitchTipWebhookPayload.model_validate(data)
|
||||
except ValidationError:
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
if payload.text is None and not payload.attachments:
|
||||
return _format_unknown_payload(raw_body, received_at)
|
||||
|
||||
return _truncate(_format_known_payload(payload, received_at))
|
||||
|
||||
|
||||
def _alerts_configured() -> bool:
|
||||
"""Все три части ОБЯЗАНЫ быть заданы: секрет (auth), токен бота, chat_id
|
||||
темы алертов. Отсутствие любой — 503, а не тихий no-op и не fail-open."""
|
||||
return bool(
|
||||
settings.tradein_internal_auth_secret
|
||||
and settings.telegram_bot_token
|
||||
and settings.telegram_alerts_chat_id
|
||||
)
|
||||
|
||||
|
||||
def _verify_secret(provided: str) -> None:
|
||||
expected = settings.tradein_internal_auth_secret
|
||||
if not secrets.compare_digest(provided or "", expected):
|
||||
logger.warning("glitchtip webhook: invalid or missing secret query param")
|
||||
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
||||
|
||||
|
||||
@router.post("/ops/glitchtip-webhook")
|
||||
async def glitchtip_webhook(
|
||||
request: Request,
|
||||
secret: Annotated[str, Query()] = "",
|
||||
) -> dict[str, str]:
|
||||
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
||||
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
||||
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
||||
|
||||
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
||||
хендлер сам делает единственную проверку (``secret`` query-параметр).
|
||||
"""
|
||||
if not _alerts_configured():
|
||||
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
|
||||
|
||||
_verify_secret(secret)
|
||||
|
||||
raw_body = await request.body()
|
||||
received_at = datetime.now(UTC)
|
||||
text = _build_message(raw_body, received_at)
|
||||
|
||||
client = TelegramClient(settings.telegram_bot_token)
|
||||
try:
|
||||
await client.send_message(
|
||||
chat_id=settings.telegram_alerts_chat_id,
|
||||
text=text,
|
||||
message_thread_id=settings.telegram_alerts_topic_id or None,
|
||||
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
|
||||
# может легально висеть воркерные минуты ретраев.
|
||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||
)
|
||||
except TelegramApiError:
|
||||
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
|
||||
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
|
||||
|
||||
return {"status": "ok"}
|
||||
|
|
@ -997,6 +997,15 @@ class Settings(BaseSettings):
|
|||
# message_thread_id топика внутри support-группы, в который идут зеркала.
|
||||
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
|
||||
|
||||
# ── GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15) ──
|
||||
# Отдельная тема от TELEGRAM_SUPPORT_TOPIC_ID выше — алерты об ошибках прода
|
||||
# НЕ должны литься в топик, куда пишут живые клиенты. См. app/api/v1/glitchtip.py.
|
||||
# Пусто/0 = вебхук отвечает 503 "not configured" (fail-closed, не fail-open —
|
||||
# это единственный auth-рубеж эндпоинта, в отличие от rbac-путей).
|
||||
# ENV: TELEGRAM_ALERTS_CHAT_ID, TELEGRAM_ALERTS_TOPIC_ID.
|
||||
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
|
||||
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
|
||||
|
||||
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
||||
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
||||
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
||||
|
|
|
|||
|
|
@ -86,6 +86,12 @@ _PUBLIC_PATHS = frozenset(
|
|||
# не секрет, читает только process env — быстрая справка для клиента/
|
||||
# поддержки/смоук-теста, не должна требовать сессию.
|
||||
"/api/v1/trade-in/version",
|
||||
# GlitchTip webhook → Telegram (app/api/v1/glitchtip.py): вызывается
|
||||
# ИЗ glitchtip-worker (docker-сеть gendesign_shared), не может нести
|
||||
# X-Authenticated-User/сессию. Путь фиксированный и не секрет — секрет
|
||||
# это query-параметр `secret`, который проверяет сам хендлер
|
||||
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
|
||||
"/api/v1/trade-in/ops/glitchtip-webhook",
|
||||
}
|
||||
)
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from app.api.v1 import (
|
|||
brand,
|
||||
buildings,
|
||||
geocode,
|
||||
glitchtip,
|
||||
lead,
|
||||
me,
|
||||
privacy_admin,
|
||||
|
|
@ -251,6 +252,7 @@ app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]
|
|||
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
|
||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
|
||||
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
|
||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||
|
|
|
|||
291
tradein-mvp/backend/tests/test_glitchtip_webhook.py
Normal file
291
tradein-mvp/backend/tests/test_glitchtip_webhook.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""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
|
||||
Loading…
Add table
Reference in a new issue