integrity_scanner_fuer_stat.../tests/test_alerter.py
Dieter Schlüter cae3dbb985 feat: KI-gestützte Inhaltsanalyse via OpenRouter (hash-gegated)
Optionale semantische Prüfung von Text und Bildern (inkl. OCR) auf
problematische Inhalte ohne Link-Signal: Pornografie, Propaganda,
diffamierende/strafbare Texte, versteckter Spam, themenfremde Werbung,
widersprüchliche Aussagen. Bewertet die thematische Passung zum
deklarierten site_context — eingestreute Heimat-Begriffe täuschen die
Erkennung nicht.

Kern:
- scanner/ai_analyzer.py: run_ai_analysis + score_ai_findings.
  Hash-Gate über data/ai_ledger.json → unveränderte Inhalte = Cache-
  Treffer = kein API-Call. Nur neue/geänderte Inhalte kosten etwas.
- Modell-Kette mit zweifacher Eskalation (Stufe 1+2 free, Stufe 3
  günstig bezahlt); eskaliert bei Fehler ODER Timeout (attempt_timeout).
  Erfolgreiches Modell wird im Ledger vermerkt.
- KI-Funde sind auf GELB gedeckelt — ROT bleibt harten Integritäts-
  Signalen vorbehalten. Graceful degradation: ohne Key/bei Fehler wird
  übersprungen, Scan läuft unverändert weiter.

Integration:
- baseline.py: load/save_ai_ledger, dismiss_ai_entries.
- config.py: ai_analysis-Block + ai_* Scoring-Schlüssel.
- __main__.py: Einhängung in cmd_scan/cmd_check, ai-dismiss-Subcommand,
  approve quittiert zugehörige Funde, status-Anzeige, diff-only + report.
- alerter.py + __main__.py: beanstandete Dateien erscheinen mit URL,
  Begründung und Quittier-Fingerprint in E-Mail UND Markdown-Report.
- plain.py: laienverständliche KI-Sätze.

API-Key nur aus Umgebungsvariable (OPENROUTER_API_KEY). Audio/Video als
abschaltbare Hooks vorbereitet (default aus).

Modelle live gegen OpenRouter verifiziert; Demo auf bredelar.info zeigte
korrekte Erkennung (echter Inhalt clean, eingeschleuster Casino-Spam mit
Heimat-Begriffen als hidden_spam erkannt).

Tests: 227 grün (+26 für ai_analyzer, +1 für E-Mail-KI-Abschnitt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:04:33 +02:00

268 lines
11 KiB
Python

"""Tests for scanner/alerter.py — alert dispatch (e-mail + webhook)."""
import os
from unittest.mock import MagicMock, call, patch
import pytest
from scanner.alerter import _make_subject, _format_body, send_alert
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _report(level: str = "yellow", target: str = "https://example.de") -> dict:
return {
"target": target,
"assessment": {
"level": level,
"score": 30 if level == "yellow" else 80,
"reasons": ["[TEST] Testbefund"],
"exit_code": 1 if level == "yellow" else 2,
},
"diff": {
"new_external_domains": [],
"new_internal_urls": [],
"missing_internal_urls": [],
"page_diffs": [],
"new_broken_urls": [],
"known_broken_urls": [],
},
}
def _cfg(
enabled: bool = True,
min_level: str = "yellow",
recipients: list | None = None,
pw_env: str = "TEST_SMTP_PW",
webhook_enabled: bool = False,
webhook_url: str = "",
) -> dict:
return {
"alerting": {
"min_level": min_level,
"email": {
"enabled": enabled,
"smtp_host": "mail.example.de",
"smtp_port": 587,
"smtp_tls": True,
"smtp_user": "scanner",
"smtp_password_env": pw_env,
"from": "scanner@example.de",
"to": recipients if recipients is not None else ["admin@example.de"],
},
"webhook": {
"enabled": webhook_enabled,
"url": webhook_url,
},
}
}
# ---------------------------------------------------------------------------
# _make_subject
# ---------------------------------------------------------------------------
class TestMakeSubject:
def test_yellow_subject_contains_target(self):
subj = _make_subject(_report("yellow", "https://meine-seite.de"))
assert "meine-seite.de" in subj
def test_red_subject_contains_aufmerksamkeit(self):
subj = _make_subject(_report("red"))
assert "Aufmerksamkeit" in subj
def test_green_subject_contains_ordnung(self):
subj = _make_subject(_report("green"))
assert "Ordnung" in subj
def test_each_level_has_emoji(self):
for level, emoji in [("green", "🟢"), ("yellow", "🟡"), ("red", "🔴")]:
assert emoji in _make_subject(_report(level))
# ---------------------------------------------------------------------------
# _format_body
# ---------------------------------------------------------------------------
class TestFormatBody:
def test_body_contains_target(self):
body = _format_body(_report("yellow", "https://meine-seite.de"))
assert "meine-seite.de" in body
def test_body_contains_level(self):
body = _format_body(_report("red"))
assert "RED" in body
def test_body_contains_reasons(self):
body = _format_body(_report("yellow"))
assert "[TEST] Testbefund" in body
def test_body_contains_workflow_hint(self):
body = _format_body(_report("yellow"))
assert "python -m scanner" in body
def test_ai_finding_includes_url_and_reason(self):
"""Beanstandete Dateien müssen mit URL und Begründung im Body stehen."""
rep = _report("yellow")
rep["ai_result"] = {
"checked": 1, "cache_hits": 0, "api_calls": 1, "skipped": None,
"findings": [{
"kind": "image", "url": "https://x.de/galerie",
"asset_url": "https://x.de/img/bad.jpg", "fingerprint": "a1b2c3d4",
"category": "pornography", "severity": "high", "confidence": 0.95,
"explanation": "Explizite Darstellung, passt nicht zum Thema.",
}],
}
body = _format_body(rep)
assert "https://x.de/img/bad.jpg" in body # URL der Datei
assert "Explizite Darstellung" in body # Begründung
assert "ai-dismiss --hash a1b2c3d4" in body # Quittier-Hinweis
# ---------------------------------------------------------------------------
# send_alert — level filtering
# ---------------------------------------------------------------------------
class TestSendAlertLevelFilter:
@patch("scanner.alerter.smtplib.SMTP")
def test_green_below_yellow_min_suppressed(self, mock_smtp):
send_alert(_report("green"), _cfg(min_level="yellow"))
mock_smtp.assert_not_called()
@patch("scanner.alerter.smtplib.SMTP")
def test_yellow_meets_yellow_min_sent(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
send_alert(_report("yellow"), _cfg(min_level="yellow"))
mock_smtp.assert_called_once()
@patch("scanner.alerter.smtplib.SMTP")
def test_red_meets_yellow_min_sent(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
send_alert(_report("red"), _cfg(min_level="yellow"))
mock_smtp.assert_called_once()
@patch("scanner.alerter.smtplib.SMTP")
def test_yellow_below_red_min_suppressed(self, mock_smtp):
send_alert(_report("yellow"), _cfg(min_level="red"))
mock_smtp.assert_not_called()
@patch("scanner.alerter.smtplib.SMTP")
def test_red_meets_red_min_sent(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
send_alert(_report("red"), _cfg(min_level="red"))
mock_smtp.assert_called_once()
# ---------------------------------------------------------------------------
# send_alert — e-mail enabled/disabled
# ---------------------------------------------------------------------------
class TestSendAlertEmail:
@patch("scanner.alerter.smtplib.SMTP")
def test_email_disabled_no_smtp(self, mock_smtp):
send_alert(_report("yellow"), _cfg(enabled=False))
mock_smtp.assert_not_called()
@patch("scanner.alerter.smtplib.SMTP")
def test_no_recipients_no_send(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
send_alert(_report("yellow"), _cfg(recipients=[]))
mock_smtp.assert_not_called()
@patch("scanner.alerter.smtplib.SMTP")
def test_starttls_called_when_tls_enabled(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
server = MagicMock()
mock_smtp.return_value.__enter__ = MagicMock(return_value=server)
mock_smtp.return_value.__exit__ = MagicMock(return_value=False)
send_alert(_report("yellow"), _cfg())
server.starttls.assert_called_once()
@patch("scanner.alerter.smtplib.SMTP")
def test_starttls_not_called_when_tls_disabled(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
server = MagicMock()
mock_smtp.return_value.__enter__ = MagicMock(return_value=server)
mock_smtp.return_value.__exit__ = MagicMock(return_value=False)
cfg = _cfg()
cfg["alerting"]["email"]["smtp_tls"] = False
send_alert(_report("yellow"), cfg)
server.starttls.assert_not_called()
@patch("scanner.alerter.smtplib.SMTP")
def test_login_called_with_user_and_password(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "geheim")
server = MagicMock()
mock_smtp.return_value.__enter__ = MagicMock(return_value=server)
mock_smtp.return_value.__exit__ = MagicMock(return_value=False)
send_alert(_report("yellow"), _cfg())
server.login.assert_called_once_with("scanner", "geheim")
@patch("scanner.alerter.smtplib.SMTP")
def test_login_skipped_when_no_password(self, mock_smtp, monkeypatch):
monkeypatch.delenv("TEST_SMTP_PW", raising=False)
server = MagicMock()
mock_smtp.return_value.__enter__ = MagicMock(return_value=server)
mock_smtp.return_value.__exit__ = MagicMock(return_value=False)
send_alert(_report("yellow"), _cfg())
server.login.assert_not_called()
@patch("scanner.alerter.smtplib.SMTP")
def test_send_message_called(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
server = MagicMock()
mock_smtp.return_value.__enter__ = MagicMock(return_value=server)
mock_smtp.return_value.__exit__ = MagicMock(return_value=False)
send_alert(_report("yellow"), _cfg())
server.send_message.assert_called_once()
@patch("scanner.alerter.smtplib.SMTP")
def test_smtp_exception_does_not_raise(self, mock_smtp, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
mock_smtp.side_effect = ConnectionRefusedError("Connection refused")
# Must not propagate
send_alert(_report("yellow"), _cfg())
# ---------------------------------------------------------------------------
# send_alert — webhook
# ---------------------------------------------------------------------------
class TestSendAlertWebhook:
@patch("scanner.alerter.requests.post")
@patch("scanner.alerter.smtplib.SMTP")
def test_webhook_called_when_enabled(self, mock_smtp, mock_post, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
cfg = _cfg(webhook_enabled=True, webhook_url="https://hooks.example.de/abc")
send_alert(_report("yellow"), cfg)
mock_post.assert_called_once()
assert mock_post.call_args[0][0] == "https://hooks.example.de/abc"
@patch("scanner.alerter.requests.post")
def test_webhook_not_called_when_disabled(self, mock_post):
send_alert(_report("yellow"), _cfg(enabled=False, webhook_enabled=False))
mock_post.assert_not_called()
@patch("scanner.alerter.requests.post")
@patch("scanner.alerter.smtplib.SMTP")
def test_webhook_payload_contains_level(self, mock_smtp, mock_post, monkeypatch):
monkeypatch.setenv("TEST_SMTP_PW", "secret")
cfg = _cfg(webhook_enabled=True, webhook_url="https://hooks.example.de/abc")
send_alert(_report("red"), cfg)
payload = mock_post.call_args[1]["json"]
assert payload["level"] == "red"
@patch("scanner.alerter.requests.post")
def test_webhook_exception_does_not_raise(self, mock_post):
mock_post.side_effect = Exception("Network error")
cfg = _cfg(enabled=False, webhook_enabled=True, webhook_url="https://hooks.example.de/x")
# green is below min_level yellow → suppressed anyway, but let's test enabled+green=red path
cfg["alerting"]["min_level"] = "green"
send_alert(_report("green"), cfg)