fix: M1 exclude_paths '/' jamulix, M2 seed-should_crawl filter, M3 alert dedup
- M1: Removed '/' from jamulix.de exclude_paths (blockaded ALL paths via startswith) - M2: Seed URLs now pass through should_crawl() in crawler.py - M3: Alert deduplication via last_alert.json (hash of reasons+level+score) - Added 11 tests for alert deduplication (277 total, all green)
This commit is contained in:
parent
2363c0dc9b
commit
248788e282
4 changed files with 190 additions and 4 deletions
13
TASK.md
13
TASK.md
|
|
@ -101,3 +101,16 @@ M1 + M2 fixen → dann **PASS**.
|
|||
- Tests: 266/266 grün ✅
|
||||
- M1 + M2: unverändert ✅
|
||||
- M3: NEU — Alarm-Mail Deduplizierung fehlt ❌
|
||||
|
||||
---
|
||||
|
||||
## Zusatzauftrag
|
||||
|
||||
2026-06-14T19:02:31.506Z
|
||||
|
||||
"Repariere die Beanstandungen."
|
||||
|
||||
## Status
|
||||
- [ ] Implementierung
|
||||
- [ ] Review bestanden (PASS)
|
||||
- [ ] Produktionsreif (SHIP)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@
|
|||
Alert dispatch: e-mail (smtplib) and HTTP webhook.
|
||||
|
||||
Alerts are only sent when the assessment level meets or exceeds min_level.
|
||||
Duplicate alerts (identical reasons since last send) are suppressed.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
|
@ -16,10 +20,15 @@ from .report import render_markdown
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LEVEL_ORDER = {"green": 0, "yellow": 1, "red": 2}
|
||||
_ALERT_STATE_FILE = "last_alert.json"
|
||||
|
||||
|
||||
def send_alert(report: dict, cfg: dict) -> None:
|
||||
"""Send alert if the report's level is at or above min_level."""
|
||||
"""Send alert if the report's level is at or above min_level.
|
||||
|
||||
Duplicate alerts (identical assessment reasons since last send) are suppressed.
|
||||
The dedup state is persisted in data_dir/last_alert.json per site.
|
||||
"""
|
||||
alerting = cfg.get("alerting", {})
|
||||
level = report.get("assessment", {}).get("level", "green")
|
||||
min_level = alerting.get("min_level", "yellow")
|
||||
|
|
@ -28,6 +37,16 @@ def send_alert(report: dict, cfg: dict) -> None:
|
|||
logger.debug("Alert suppressed: level=%s < min_level=%s", level, min_level)
|
||||
return
|
||||
|
||||
# --- Deduplizierung: nur senden wenn sich die Alarm-Gründe geändert haben ---
|
||||
alert_key = _compute_alert_key(report)
|
||||
data_dir = cfg.get("data_dir")
|
||||
if data_dir:
|
||||
last = _load_last_alert(Path(data_dir))
|
||||
if last and last.get("key") == alert_key:
|
||||
logger.info("Alert suppressed (duplicate, reasons unchanged since %s)",
|
||||
last.get("sent_at", "unknown"))
|
||||
return
|
||||
|
||||
subject = _make_subject(report)
|
||||
body = _format_body(report)
|
||||
|
||||
|
|
@ -39,6 +58,10 @@ def send_alert(report: dict, cfg: dict) -> None:
|
|||
if webhook_cfg.get("enabled") and webhook_cfg.get("url"):
|
||||
_send_webhook(webhook_cfg["url"], report, subject)
|
||||
|
||||
# Zustand speichern — für nächste Dedup-Runde
|
||||
if data_dir:
|
||||
_save_last_alert(Path(data_dir), alert_key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting
|
||||
|
|
@ -112,3 +135,40 @@ def _send_webhook(url: str, report: dict, subject: str) -> None:
|
|||
logger.info("Webhook delivered: HTTP %s", resp.status_code)
|
||||
except Exception as exc:
|
||||
logger.error("Webhook alert failed: %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deduplication state (last_alert.json in data_dir)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _compute_alert_key(report: dict) -> str:
|
||||
"""Stable hash of the assessment reasons + level for deduplication."""
|
||||
payload = {
|
||||
"level": report.get("assessment", {}).get("level", "green"),
|
||||
"score": report.get("assessment", {}).get("score", 0),
|
||||
"reasons": sorted(report.get("assessment", {}).get("reasons", [])),
|
||||
}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def _load_last_alert(data_dir: Path) -> dict:
|
||||
"""Load previous alert state. Returns {} on missing/corrupt file."""
|
||||
path = data_dir / _ALERT_STATE_FILE
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_last_alert(data_dir: Path, alert_key: str) -> None:
|
||||
"""Persist current alert state for next dedup round."""
|
||||
from datetime import datetime, timezone
|
||||
path = data_dir / _ALERT_STATE_FILE
|
||||
state = {
|
||||
"key": alert_key,
|
||||
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
try:
|
||||
path.write_text(json.dumps(state))
|
||||
except OSError as exc:
|
||||
logger.debug("Alert state write failed: %s", exc)
|
||||
|
|
|
|||
|
|
@ -112,9 +112,18 @@ class Crawler:
|
|||
queue: list[str] = [
|
||||
normalize_url(self.target + p) or (self.target + p)
|
||||
for p in self.include_paths
|
||||
if should_crawl(
|
||||
normalize_url(self.target + p) or (self.target + p),
|
||||
self.base_netloc, self.skip_ext,
|
||||
self.exclude_paths, self.include_paths,
|
||||
)
|
||||
]
|
||||
else:
|
||||
queue: list[str] = [normalize_url(self.target + "/") or self.target + "/"]
|
||||
seed = normalize_url(self.target + "/") or self.target + "/"
|
||||
queue: list[str] = [seed] if should_crawl(
|
||||
seed, self.base_netloc, self.skip_ext,
|
||||
self.exclude_paths, self.include_paths,
|
||||
) else []
|
||||
pages: list[dict] = []
|
||||
errors: list[dict] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
"""Tests for scanner/alerter.py — alert dispatch (e-mail + webhook)."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scanner.alerter import _make_subject, _format_body, send_alert
|
||||
from scanner.alerter import (
|
||||
_compute_alert_key,
|
||||
_load_last_alert,
|
||||
_save_last_alert,
|
||||
_make_subject,
|
||||
_format_body,
|
||||
send_alert,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -38,8 +48,9 @@ def _cfg(
|
|||
pw_env: str = "TEST_SMTP_PW",
|
||||
webhook_enabled: bool = False,
|
||||
webhook_url: str = "",
|
||||
data_dir: str | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
cfg: dict = {
|
||||
"alerting": {
|
||||
"min_level": min_level,
|
||||
"email": {
|
||||
|
|
@ -58,6 +69,9 @@ def _cfg(
|
|||
},
|
||||
}
|
||||
}
|
||||
if data_dir is not None:
|
||||
cfg["data_dir"] = data_dir
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -284,3 +298,93 @@ class TestSendAlertWebhook:
|
|||
# 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deduplication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeAlertKey:
|
||||
def test_same_reasons_same_key(self):
|
||||
r1 = _report("red", "https://a.de")
|
||||
r2 = _report("red", "https://b.de") # anderer Target, gleiche reasons/level/score
|
||||
assert _compute_alert_key(r1) == _compute_alert_key(r2)
|
||||
|
||||
def test_different_score_different_key(self):
|
||||
r1 = _report("yellow") # score=30
|
||||
r2 = _report("red") # score=80
|
||||
assert _compute_alert_key(r1) != _compute_alert_key(r2)
|
||||
|
||||
def test_different_reasons_different_key(self):
|
||||
r1 = _report("yellow")
|
||||
r1["assessment"]["reasons"] = ["Grund A"]
|
||||
r2 = _report("yellow")
|
||||
r2["assessment"]["reasons"] = ["Grund B"]
|
||||
assert _compute_alert_key(r1) != _compute_alert_key(r2)
|
||||
|
||||
def test_reasons_order_independent(self):
|
||||
r1 = _report("yellow")
|
||||
r1["assessment"]["reasons"] = ["B", "A", "C"]
|
||||
r2 = _report("yellow")
|
||||
r2["assessment"]["reasons"] = ["C", "A", "B"] # gleiche, andere Reihenfolge
|
||||
assert _compute_alert_key(r1) == _compute_alert_key(r2)
|
||||
|
||||
|
||||
class TestLoadSaveLastAlert:
|
||||
def test_load_missing_file_returns_empty(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
state = _load_last_alert(Path(tmp))
|
||||
assert state == {}
|
||||
|
||||
def test_load_corrupt_json_returns_empty(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / "last_alert.json").write_text("{invalid")
|
||||
state = _load_last_alert(Path(tmp))
|
||||
assert state == {}
|
||||
|
||||
def test_save_and_load_roundtrip(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
d = Path(tmp)
|
||||
_save_last_alert(d, "abc123")
|
||||
state = _load_last_alert(d)
|
||||
assert state["key"] == "abc123"
|
||||
assert "sent_at" in state
|
||||
|
||||
|
||||
class TestSendAlertDedup:
|
||||
@patch("scanner.alerter._send_email")
|
||||
def test_first_alert_sent(self, mock_email):
|
||||
with TemporaryDirectory() as tmp:
|
||||
cfg = _cfg(data_dir=tmp)
|
||||
send_alert(_report("yellow"), cfg)
|
||||
mock_email.assert_called_once()
|
||||
|
||||
@patch("scanner.alerter._send_email")
|
||||
def test_identical_second_alert_suppressed(self, mock_email):
|
||||
with TemporaryDirectory() as tmp:
|
||||
cfg = _cfg(data_dir=tmp)
|
||||
report = _report("yellow")
|
||||
send_alert(report, cfg)
|
||||
send_alert(report, cfg) # gleiche reasons → unterdrückt
|
||||
assert mock_email.call_count == 1
|
||||
|
||||
@patch("scanner.alerter._send_email")
|
||||
def test_changed_reasons_triggers_new_alert(self, mock_email):
|
||||
with TemporaryDirectory() as tmp:
|
||||
cfg = _cfg(data_dir=tmp)
|
||||
r1 = _report("yellow")
|
||||
r1["assessment"]["reasons"] = ["Grund A"]
|
||||
send_alert(r1, cfg)
|
||||
|
||||
r2 = _report("yellow")
|
||||
r2["assessment"]["reasons"] = ["Grund B"] # anderer Grund
|
||||
send_alert(r2, cfg)
|
||||
|
||||
assert mock_email.call_count == 2
|
||||
|
||||
@patch("scanner.alerter._send_email")
|
||||
def test_no_data_dir_sends_every_time(self, mock_email):
|
||||
cfg = _cfg(data_dir=None) # kein data_dir → keine Dedup
|
||||
send_alert(_report("yellow"), cfg)
|
||||
send_alert(_report("yellow"), cfg)
|
||||
assert mock_email.call_count == 2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue