Includes atomic migrations, per-rule reminder semantics, first-run helper script and the note on the undecided UI chat idea. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
141 lines
6.3 KiB
Python
141 lines
6.3 KiB
Python
import os
|
|
from datetime import datetime
|
|
|
|
from app.alerts import AlertActions, AlertRepository, SendLimiter, SystemNotifier
|
|
from app.alerts.system_text import baseline_message, outage_plain, restored_plain
|
|
from app.checkmk import FAILURE_UNEXPECTED, FAILURE_UNREACHABLE
|
|
from app.constants import UI_BASE_URL
|
|
from app.crypto import SecretBox
|
|
from app.db import Database
|
|
from app.models import HOST_SERVICE_NAME, KIND_SYSTEM, StateRow
|
|
from app.poller import OutageNotice, RestoredNotice
|
|
from app.settings import SettingsStore
|
|
from tests.test_pipeline import FakeAdapter
|
|
|
|
SINCE = int(datetime(2026, 11, 6, 14, 5).timestamp())
|
|
MINUTE = 60
|
|
|
|
|
|
def row(host="h1", service="cpu", state=2, output="geheimer output"):
|
|
return StateRow(host, service, state, output, 1, False, False, state, False)
|
|
|
|
|
|
class SysEnv:
|
|
def __init__(self, limit=15, adapters=None):
|
|
self.db = Database(":memory:")
|
|
self.repo = AlertRepository(self.db)
|
|
self.store = SettingsStore(self.db, SecretBox(os.urandom(32)))
|
|
self.adapters = adapters or [FakeAdapter()]
|
|
self.limiter = SendLimiter(lambda: 1000.0)
|
|
self.reset_keys = []
|
|
|
|
async def provider():
|
|
return self.adapters
|
|
|
|
self.notifier = SystemNotifier(self.repo, provider, self.limiter, lambda: limit,
|
|
AlertActions(self.store), on_baseline=self.reset_keys.extend,
|
|
clock=lambda: 2000.0)
|
|
|
|
def alerts(self):
|
|
return self.repo.recent()
|
|
|
|
|
|
async def test_unreachable_message_text_and_history_row():
|
|
env = SysEnv()
|
|
await env.notifier.outage(OutageNotice(FAILURE_UNREACHABLE, SINCE, "ConnectError"))
|
|
(target, text), = env.adapters[0].sent
|
|
assert target == "chat1" and "Checkmk nicht erreichbar seit 14:05" in text and "<b>checkbot</b>" in text
|
|
(alert,) = env.alerts()
|
|
assert (alert["kind"], alert["host"], alert["service"], alert["ai_status"]) == (
|
|
KIND_SYSTEM, "checkbot", "Checkmk-Verbindung", "skipped")
|
|
assert alert["output"] == "Checkmk nicht erreichbar seit 14:05" and alert["suppressed"] == 0
|
|
|
|
|
|
async def test_unexpected_message_names_short_reason_only():
|
|
plain = outage_plain(OutageNotice(FAILURE_UNEXPECTED, SINCE, "HTTP 403"))
|
|
assert plain == "Antwort von Checkmk unerwartet seit 14:05: HTTP 403"
|
|
|
|
|
|
async def test_restored_message_has_duration():
|
|
assert restored_plain(RestoredNotice(SINCE, SINCE + 12 * MINUTE)) == "Checkmk wieder erreichbar nach 12 min"
|
|
env = SysEnv()
|
|
await env.notifier.restored(RestoredNotice(SINCE, SINCE + 3 * 3600 + 12 * MINUTE))
|
|
assert "wieder erreichbar nach 3 h 12 min" in env.adapters[0].sent[0][1]
|
|
|
|
|
|
async def test_system_message_text_is_escaped():
|
|
env = SysEnv()
|
|
await env.notifier.outage(OutageNotice(FAILURE_UNEXPECTED, SINCE, "<b>x</b>&"))
|
|
text = env.adapters[0].sent[0][1]
|
|
assert "<b>x</b>&" in text and "<b>x</b>" not in text
|
|
|
|
|
|
async def test_system_messages_are_never_blocked_by_send_limit_but_count_against_it():
|
|
env = SysEnv(limit=1)
|
|
target = ("fake", "chat1")
|
|
assert env.limiter.acquire(target, 1)
|
|
await env.notifier.outage(OutageNotice(FAILURE_UNREACHABLE, SINCE, "ConnectError"))
|
|
await env.notifier.restored(RestoredNotice(SINCE, SINCE + 60))
|
|
assert len(env.adapters[0].sent) == 2
|
|
assert env.limiter.acquire(target, 1) is False
|
|
|
|
|
|
async def test_goes_to_default_targets_of_all_adapters():
|
|
tg, mx = FakeAdapter("telegram", "tg1"), FakeAdapter("matrix", "!r:s")
|
|
env = SysEnv(adapters=[tg, mx])
|
|
await env.notifier.outage(OutageNotice(FAILURE_UNREACHABLE, SINCE, "ConnectError"))
|
|
assert [s[0] for s in tg.sent] == ["tg1"] and [s[0] for s in mx.sent] == ["!r:s"]
|
|
rows = env.db.query("SELECT backend, target FROM deliveries ORDER BY backend")
|
|
assert [(r["backend"], r["target"]) for r in rows] == [("matrix", "!r:s"), ("telegram", "tg1")]
|
|
|
|
|
|
async def test_history_row_is_written_without_any_adapter():
|
|
env = SysEnv(adapters=[])
|
|
env.adapters = []
|
|
await env.notifier.outage(OutageNotice(FAILURE_UNREACHABLE, SINCE, "ConnectError"))
|
|
assert len(env.alerts()) == 1
|
|
|
|
|
|
async def test_matrix_message_contains_details_link_when_base_url_is_set():
|
|
mx = FakeAdapter("matrix", "!r:s")
|
|
env = SysEnv(adapters=[mx])
|
|
env.store.set(UI_BASE_URL, "https://bot.example.de", "t")
|
|
await env.notifier.outage(OutageNotice(FAILURE_UNREACHABLE, SINCE, "ConnectError"))
|
|
alert_id = env.alerts()[0]["id"]
|
|
assert f"Details: https://bot.example.de/alerts#alert-{alert_id}" in mx.sent[0][1]
|
|
|
|
|
|
async def test_baseline_message_sent_without_output_and_resets_gate():
|
|
env = SysEnv()
|
|
rows = [row(), row(service=HOST_SERVICE_NAME, state=2), row(service="disk", state=1)]
|
|
await env.notifier.baseline(rows)
|
|
text = env.adapters[0].sent[0][1]
|
|
assert "Baseline aufgenommen: 3 offene Probleme" in text and "geheimer output" not in text
|
|
assert env.reset_keys == [("h1", "cpu"), ("h1", HOST_SERVICE_NAME), ("h1", "disk")]
|
|
(alert,) = env.alerts()
|
|
assert alert["service"] == "Baseline" and alert["kind"] == KIND_SYSTEM
|
|
|
|
|
|
def test_baseline_counts_and_ranking_hosts_first_then_crit_unknown_warn():
|
|
rows = [row(host="a", service="w", state=1), row(host="a", service="u", state=3),
|
|
row(host="a", service="c", state=2), row(host="b", service=HOST_SERVICE_NAME, state=3),
|
|
row(host="c", service=HOST_SERVICE_NAME, state=2), row(host="a", service="c2", state=2)]
|
|
text = baseline_message(rows)
|
|
assert "Hosts: DOWN 1, UNREACH 1" in text and "Services: CRIT 2, UNKNOWN 1, WARN 1" in text
|
|
order = [text.index(marker) for marker in ("<b>c</b> Host DOWN", "<b>b</b> Host UNREACH", " c: CRIT",
|
|
" c2: CRIT", " u: UNKNOWN", " w: WARN")]
|
|
assert order == sorted(order)
|
|
|
|
|
|
def test_baseline_lists_at_most_ten_and_counts_the_rest():
|
|
rows = [row(host=f"h{i:02d}", service="s", state=2) for i in range(12)]
|
|
text = baseline_message(rows)
|
|
assert "Die 10 kritischsten" in text and text.count("•") == 10 and "… und 2 weitere" in text
|
|
|
|
|
|
def test_baseline_escapes_foreign_names_and_handles_empty_state():
|
|
text = baseline_message([row(host="<b>evil</b>", service="<i>x</i>")])
|
|
assert "<b>evil</b>" not in text and "<b>evil</b>" in text and "<i>x</i>" in text
|
|
empty = baseline_message([])
|
|
assert "0 offene Probleme" in empty and "Hosts: keine" in empty and "kritischsten" not in empty
|