diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9e04bd6b..2f0d592d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/tradein-mvp/backend/app/api/v1/glitchtip.py b/tradein-mvp/backend/app/api/v1/glitchtip.py new file mode 100644 index 00000000..0f051ee7 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/glitchtip.py @@ -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"} diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 8d6cf935..cac916e8 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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. diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 0dd654ff..2a0c7eaf 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -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) перед diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 8b76910e..9c64b87a 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -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"]) diff --git a/tradein-mvp/backend/tests/test_glitchtip_webhook.py b/tradein-mvp/backend/tests/test_glitchtip_webhook.py new file mode 100644 index 00000000..2a777597 --- /dev/null +++ b/tradein-mvp/backend/tests/test_glitchtip_webhook.py @@ -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