All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 23s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m57s
CI / backend-tests (pull_request) Successful in 7m35s
GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а
BaseHTTPRequestHandler печатает строку запроса целиком: в строке доступа
(log_request) и в тексте ошибки разбора (log_error, «Bad request syntax
('POST /glitchtip?secret=…')»). Оба пути сходятся в log_message — маскируем
там одним выражением, тем же, что у бэкенда МЕРЫ (#3154, log_scrub.py) и у
Alloy (#3354). Импортировать его нельзя: сервис намеренно без зависимостей.
Тест в backend/tests/ops/test_3078_alert_ack.py (его гоняет CI по ops/**):
настоящий сокет, три строки запроса — доступ, имя с префиксом, ошибка
разбора; значения в записях нет, `=***` стоит в ожидаемом числе записей.
Ротация секрета — за владельцем, здесь не делается.
Refs #3576
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
11 KiB
Python
230 lines
11 KiB
Python
"""Кнопка подтверждения инцидента: что реально уходит в Telegram (#3078).
|
||
|
||
ЗАЧЕМ СЕРВИС ВООБЩЕ. Alertmanager пишет в Telegram сам, но инлайн-клавиатуру его
|
||
интеграция не поддерживает. Без кнопки нет обратной связи «человек увидел и взял
|
||
в работу» — 27.08 продукты лежали 10 часов, и вопрос «а кто-нибудь это читает»
|
||
было не к кому адресовать.
|
||
|
||
ЧТО СТОРОЖАТ ТЕСТЫ. Сервис маленький, но в нём три места, где ошибка не видна
|
||
глазами и проявится только в аварию — то есть тогда, когда проверять уже поздно:
|
||
|
||
1. Кнопка не должна теряться, но и не должна ронять сообщение. Если прицепить
|
||
клавиатуру не удалось, алерт обязан уйти БЕЗ неё: сообщение важнее кнопки.
|
||
2. Повторное нажатие не должно слать второй «принято» — ссылка живёт сутки, по
|
||
ней кликнут дважды, и дубль в теме выглядит как второй человек.
|
||
3. Токен должен быть неугадываемым и одноразовым: эндпоинт публичный.
|
||
|
||
Тесты дёргают настоящие функции модуля, подменяя ровно один шов — вызов Bot API.
|
||
Сеть не трогается, а всё, что ушло бы в неё, записывается и проверяется.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import logging
|
||
import socket
|
||
import sys
|
||
import threading
|
||
from http.server import ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from types import ModuleType
|
||
|
||
import pytest
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||
APP = REPO_ROOT / "ops" / "metrics" / "alert-ack" / "app.py"
|
||
|
||
FIRING = {
|
||
"status": "firing",
|
||
"commonLabels": {"alertname": "HostAgentDown", "host": "apps", "severity": "critical"},
|
||
"alerts": [
|
||
{
|
||
"labels": {"alertname": "HostAgentDown"},
|
||
"annotations": {
|
||
"summary": "Агент метрик не отвечает",
|
||
"description": "15 минут тишины",
|
||
},
|
||
}
|
||
],
|
||
}
|
||
RESOLVED = {**FIRING, "status": "resolved"}
|
||
|
||
|
||
@pytest.fixture
|
||
def app(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
|
||
"""Загрузить сервис с предсказуемым окружением и подменённым Bot API."""
|
||
assert APP.is_file(), f"нет {APP} — сервис переехал, тест ослеп"
|
||
monkeypatch.setenv("METRICS_TELEGRAM_BOT_TOKEN", "123:FAKE")
|
||
monkeypatch.setenv("METRICS_TELEGRAM_CHAT_ID", "-1004443088679")
|
||
monkeypatch.setenv("METRICS_TELEGRAM_TOPIC_ID", "158")
|
||
monkeypatch.setenv("METRICS_TELEGRAM_ONCALL", "@leks361")
|
||
monkeypatch.setenv("ALERT_ACK_PUBLIC_URL", "https://metrics.gendsgn.ru")
|
||
|
||
spec = importlib.util.spec_from_file_location("alert_ack_under_test", APP)
|
||
assert spec and spec.loader
|
||
mod = importlib.util.module_from_spec(spec)
|
||
sys.modules["alert_ack_under_test"] = mod
|
||
spec.loader.exec_module(mod)
|
||
|
||
sent: list[tuple[str, dict]] = []
|
||
|
||
def fake_tg(method: str, payload: dict) -> dict:
|
||
sent.append((method, payload))
|
||
return {"ok": True, "result": {"message_id": 1000 + len(sent)}}
|
||
|
||
mod._tg = fake_tg # единственный шов, ради него тест и существует
|
||
mod.sent = sent
|
||
mod._PENDING.clear()
|
||
return mod
|
||
|
||
|
||
def test_gorjaschiy_incident_uhodit_s_knopkoy(app: ModuleType) -> None:
|
||
"""У горящего инцидента есть кнопка со ссылкой и живой токен."""
|
||
app._send_alert(FIRING)
|
||
method, payload = app.sent[0]
|
||
assert method == "sendMessage"
|
||
assert "reply_markup" in payload, "кнопка не прицеплена"
|
||
assert "/ack/" in payload["reply_markup"], "в кнопке нет ссылки подтверждения"
|
||
assert len(app._PENDING) == 1, "токен не сохранён — нажатие будет некуда деть"
|
||
|
||
|
||
def test_soobschenie_adresovano_v_temu_i_zovyot_dezhurnogo(app: ModuleType) -> None:
|
||
"""Тема форума и упоминание — обе вещи, которые молча теряются."""
|
||
app._send_alert(FIRING)
|
||
_, payload = app.sent[0]
|
||
assert payload.get("message_thread_id") == "158", "уйдёт в общую тему форума"
|
||
assert "@leks361" in payload["text"], "дежурного не позвали"
|
||
assert "КЛИЕНТЫ ЗАТРОНУТЫ" in payload["text"]
|
||
|
||
|
||
def test_vosstanovlenie_bez_knopki(app: ModuleType) -> None:
|
||
"""У «восстановлено» подтверждать нечего — кнопки быть не должно."""
|
||
app._send_alert(RESOLVED)
|
||
_, payload = app.sent[0]
|
||
assert "reply_markup" not in payload
|
||
assert not app._PENDING, "токен выдан там, где кнопки нет"
|
||
|
||
|
||
def test_esli_knopka_ne_prikrepilas_soobschenie_vsyo_ravno_uhodit(
|
||
app: ModuleType, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Отказ Bot API на сообщении с клавиатурой → повтор без неё.
|
||
|
||
Самый важный из тестов: алерт важнее кнопки. Молчание вместо сообщения —
|
||
ровно тот отказ, ради устранения которого весь стек и заводится.
|
||
"""
|
||
calls: list[dict] = []
|
||
|
||
def flaky(method: str, payload: dict):
|
||
calls.append(payload)
|
||
# Первая попытка (с клавиатурой) не удалась, вторая — без неё.
|
||
return None if "reply_markup" in payload else {"ok": True, "result": {"message_id": 7}}
|
||
|
||
monkeypatch.setattr(app, "_tg", flaky)
|
||
app._send_alert(FIRING)
|
||
assert len(calls) == 2, "не было повтора без кнопки — алерт потерян"
|
||
assert "reply_markup" not in calls[1]
|
||
assert calls[1]["text"] == calls[0]["text"], "во второй попытке потерялся текст"
|
||
|
||
|
||
def test_nazhatie_otvechaet_v_temu_i_snimaet_knopku(app: ModuleType) -> None:
|
||
"""Нажатие: ответ в тему + снятие клавиатуры у исходного сообщения."""
|
||
app._send_alert(FIRING)
|
||
token = next(iter(app._PENDING))
|
||
app.sent.clear()
|
||
|
||
code, page = app.do_ack(token)
|
||
assert code == 200
|
||
assert "Принято в работу" in page
|
||
|
||
methods = [m for m, _ in app.sent]
|
||
assert methods == ["sendMessage", "editMessageReplyMarkup"], methods
|
||
reply = app.sent[0][1]
|
||
assert reply.get("message_thread_id") == "158", "подтверждение уйдёт не в ту тему"
|
||
assert reply.get("reply_to_message_id"), "подтверждение не привязано к сообщению инцидента"
|
||
assert "@leks361" in reply["text"], "не видно, кто именно принял"
|
||
|
||
|
||
def test_povtornoe_nazhatie_ne_shlyot_vtoroy_raz(app: ModuleType) -> None:
|
||
"""Идемпотентность: ссылка живёт сутки, по ней кликнут дважды."""
|
||
app._send_alert(FIRING)
|
||
token = next(iter(app._PENDING))
|
||
app.do_ack(token)
|
||
app.sent.clear()
|
||
|
||
code, page = app.do_ack(token)
|
||
assert code == 200
|
||
assert "Уже подтверждено" in page
|
||
assert app.sent == [], "второе нажатие отправило дубль в чат"
|
||
|
||
|
||
def test_neizvestnyy_token_nichego_ne_rasskazyvaet(app: ModuleType) -> None:
|
||
"""Публичный эндпоинт: перебор не должен получать подсказок и ничего не шлёт."""
|
||
code, page = app.do_ack("нет-такого-токена")
|
||
assert code == 404
|
||
assert app.sent == [], "неизвестный токен что-то отправил в чат"
|
||
assert "недействительна" in page
|
||
|
||
|
||
def test_token_dostatochno_dlinnyy(app: ModuleType) -> None:
|
||
"""Ссылка защищена только неугадываемостью — длина токена и есть защита."""
|
||
app._send_alert(FIRING)
|
||
token = next(iter(app._PENDING))
|
||
assert len(token) >= 20, f"слишком короткий токен: {len(token)} символов"
|
||
|
||
|
||
def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""По истечении срока ссылка мертва — иначе она копится вечно."""
|
||
app._send_alert(FIRING)
|
||
token = next(iter(app._PENDING))
|
||
app._PENDING[token]["created"] -= app.TTL_SEC + 1
|
||
app.sent.clear()
|
||
|
||
code, _ = app.do_ack(token)
|
||
assert code == 404
|
||
assert app.sent == []
|
||
|
||
|
||
# ── Секрет из query не попадает в лог (#3576) ────────────────────────────────
|
||
# GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а http.server
|
||
# печатает строку запроса целиком — и в строке доступа, и в тексте ошибки
|
||
# разбора. Значение ниже выдуманное: проверяется, что его нет ни в одной записи.
|
||
|
||
_LEAK = "leak-probe-3576-VALUE"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("request_line", "masked", "lines_with_mask"),
|
||
[
|
||
# Строка доступа (log_request), путь резервного вебхука GlitchTip.
|
||
(f"POST /glitchtip?secret={_LEAK} HTTP/1.1", "/glitchtip?secret=***", 1),
|
||
# Имя с префиксом и соседний параметр — остальная строка цела.
|
||
(f"GET /ack/x?a=1&access_token={_LEAK}&b=2 HTTP/1.1", "?a=1&access_token=***&b=2", 1),
|
||
# Ошибка разбора (log_error): stdlib кладёт строку запроса в текст ошибки,
|
||
# затем та же строка идёт в строку доступа с кодом 400 — обе записи.
|
||
(f"POST /glitchtip?secret={_LEAK} junk HTTP/1.1", "/glitchtip?secret=***", 2),
|
||
],
|
||
)
|
||
def test_sekret_iz_query_ne_popadaet_v_log(
|
||
app: ModuleType,
|
||
caplog: pytest.LogCaptureFixture,
|
||
request_line: str,
|
||
masked: str,
|
||
lines_with_mask: int,
|
||
) -> None:
|
||
caplog.set_level(logging.INFO, logger="alert-ack")
|
||
srv = ThreadingHTTPServer(("127.0.0.1", 0), app.Handler)
|
||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||
try:
|
||
with socket.create_connection(srv.server_address, timeout=5) as sock:
|
||
sock.sendall(f"{request_line}\r\nHost: x\r\nContent-Length: 0\r\n\r\n".encode())
|
||
sock.shutdown(socket.SHUT_WR)
|
||
while sock.recv(4096): # до закрытия: к этому моменту запись лога уже сделана
|
||
pass
|
||
finally:
|
||
srv.shutdown()
|
||
srv.server_close()
|
||
|
||
messages = [r.getMessage() for r in caplog.records if r.name == "alert-ack"]
|
||
assert not [m for m in messages if _LEAK in m], f"значение секрета в логе: {messages}"
|
||
assert sum(masked in m for m in messages) == lines_with_mask, messages
|