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 "checkbot" 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, "x&")) text = env.adapters[0].sent[0][1] assert "<b>x</b>&" in text and "x" 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 ("c Host DOWN", "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="evil", service="x")]) assert "evil" 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