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
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`).
318 lines
12 KiB
Python
318 lines
12 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_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
|