All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
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
Все три alert-правила GlitchTip (backend, frontend, Trade-In) сейчас шлют единственный вебхук в продуктовый бэкенд на Selectel — тот самый хост, за которым они следят. Если там упал backend или Caddy, ошибки приложения задерживаются или пропадают именно тогда, когда нужнее всего. Добавлен POST /glitchtip в alert-ack (живёт на инфраструктурном хосте Beget, не зависит от здоровья продукта): второй получатель того же Slack-совместимого payload, аутентификация секретом в заголовке X-GlitchTip-Secret или query ?secret= (тот же подход, что у tradein-mvp/backend/app/api/v1/glitchtip.py). Сообщение уходит в существующую тему клиентских инцидентов с явной пометкой «резервный канал». Секрет свой (ALERT_ACK_GLITCHTIP_SECRET), не переиспользует продуктовый TRADEIN_INTERNAL_AUTH_SECRET. Refs #3471
117 lines
5.3 KiB
Python
117 lines
5.3 KiB
Python
"""Тесты для резервного канала GlitchTip → alert-ack (#3471).
|
||
|
||
Продукт (tradein-backend на Selectel) — САМ объект наблюдения GlitchTip. Если
|
||
он лежит, основной вебхук-получатель лежит вместе с ним, и алерт об этом не
|
||
доходит именно тогда, когда нужнее всего. `POST /glitchtip` — второй
|
||
получатель на ДРУГОМ хосте (Beget, рядом с этим сервисом), не зависящий от
|
||
здоровья продукта.
|
||
|
||
Проверяем на уровне функций, а не полного HTTP-транспорта (тот же приём, что
|
||
`ops/glitchtip-auth-forwarder/test_forwarder.py`): `BaseHTTPRequestHandler`
|
||
неудобно поднимать без реального сокета, а бизнес-логика — секрет, рендер,
|
||
отправка — целиком вынесена в чистые функции модуля.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
|
||
# Env — ДО импорта app.py: BOT_TOKEN/CHAT_ID читаются на уровне модуля.
|
||
os.environ.setdefault("METRICS_TELEGRAM_BOT_TOKEN", "test-bot-token")
|
||
os.environ.setdefault("METRICS_TELEGRAM_CHAT_ID", "-1001234567890")
|
||
os.environ.setdefault("METRICS_TELEGRAM_TOPIC_ID", "158")
|
||
os.environ["ALERT_ACK_GLITCHTIP_SECRET"] = "correct-secret"
|
||
|
||
import app as alert_ack # noqa: E402
|
||
|
||
|
||
def test_verify_glitchtip_secret_accepts_correct_value() -> None:
|
||
assert alert_ack._verify_glitchtip_secret("correct-secret") is True
|
||
|
||
|
||
def test_verify_glitchtip_secret_rejects_wrong_value() -> None:
|
||
"""Неверный секрет — отказ."""
|
||
assert alert_ack._verify_glitchtip_secret("wrong-secret") is False
|
||
|
||
|
||
def test_verify_glitchtip_secret_rejects_empty_value() -> None:
|
||
assert alert_ack._verify_glitchtip_secret("") is False
|
||
|
||
|
||
def test_verify_glitchtip_secret_fails_closed_when_unconfigured(monkeypatch) -> None:
|
||
"""Пустой ALERT_ACK_GLITCHTIP_SECRET — отказ всем, а не тихий fail-open."""
|
||
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
|
||
assert alert_ack._verify_glitchtip_secret("correct-secret") is False
|
||
assert alert_ack._verify_glitchtip_secret("") is False
|
||
|
||
|
||
def test_render_glitchtip_full_payload_marks_fallback_channel() -> None:
|
||
payload = {
|
||
"text": "GlitchTip Alert: Something broke",
|
||
"attachments": [
|
||
{
|
||
"title": "TypeError: boom",
|
||
"title_link": "https://errors.gendsgn.ru/issue/1",
|
||
"text": "подробности ошибки",
|
||
"color": "#ff0000",
|
||
"fields": [{"title": "Project", "value": "tradein-backend"}],
|
||
}
|
||
],
|
||
}
|
||
text = alert_ack._render_glitchtip(payload)
|
||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||
assert "Проект: tradein-backend" in text
|
||
assert "TypeError: boom" in text
|
||
assert "https://errors.gendsgn.ru/issue/1" in text
|
||
|
||
|
||
def test_render_glitchtip_survives_payload_without_attachments() -> None:
|
||
"""Тело без attachments не роняет сервис — только текст."""
|
||
text = alert_ack._render_glitchtip({"text": "просто текст без вложений"})
|
||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||
assert "просто текст без вложений" in text
|
||
|
||
|
||
def test_render_glitchtip_survives_empty_payload() -> None:
|
||
"""Пустой словарь (нет ни text, ни attachments) — тоже не должен падать."""
|
||
text = alert_ack._render_glitchtip({})
|
||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||
assert "GlitchTip alert" in text
|
||
|
||
|
||
def test_render_glitchtip_survives_malformed_attachments() -> None:
|
||
"""attachments/fields неожиданной формы (не список, не словарь, битые
|
||
типы) — резервный канал не должен падать на кривом теле."""
|
||
payload = {
|
||
"text": "test",
|
||
"attachments": [
|
||
"not-a-dict",
|
||
{"fields": "not-a-list"},
|
||
{"fields": [{"title": "Project"}]}, # value отсутствует
|
||
None,
|
||
],
|
||
}
|
||
text = alert_ack._render_glitchtip(payload)
|
||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||
|
||
|
||
def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
|
||
"""Верный секрет уже проверен вызывающей стороной (do_POST) — здесь
|
||
проверяем, что отрендеренное сообщение реально уходит в тот же chat/topic,
|
||
что и Alertmanager-алерты этого сервиса, без кнопки подтверждения."""
|
||
calls = []
|
||
monkeypatch.setattr(alert_ack, "_tg", lambda method, payload: calls.append((method, payload)))
|
||
|
||
alert_ack._send_glitchtip_alert({"text": "boom", "attachments": []})
|
||
|
||
assert len(calls) == 1
|
||
method, sent = calls[0]
|
||
assert method == "sendMessage"
|
||
assert sent["chat_id"] == alert_ack.CHAT_ID
|
||
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
|
||
assert "reply_markup" not in sent
|
||
assert "boom" in sent["text"]
|