Files
checkbot/tests/test_system_notifier.py
T
patrickandClaude Sonnet 5.5 347f55cf7c
test / pytest (3.11) (push) Waiting to run
test / pytest (3.13) (push) Waiting to run
feat: Phase 6c baseline, ack, host alarms, outage notice, send limit, privacy mode, UI link
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>
2026-11-06 03:06:23 +01:00

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 "&lt;b&gt;x&lt;/b&gt;&amp;" 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 "&lt;b&gt;evil&lt;/b&gt;" in text and "&lt;i&gt;x&lt;/i&gt;" in text
empty = baseline_message([])
assert "0 offene Probleme" in empty and "Hosts: keine" in empty and "kritischsten" not in empty