feat: Laientauglichkeit — Auto-Modus, Klartext-Ausgabe, einfachere Doku
Ziel: ein einziger täglicher Befehl genügt, jede Meldung ist auch ohne IT-Wissen verständlich. Auto-Modus (ein Cron-Job genügt für alles): - data/state.json merkt sich, wann die Wochen-Prüfungen zuletzt liefen (baseline.py: load_state/save_state/is_check_due/mark_check_run) - `scan` führt fällige Zusatzprüfungen (Tarnung/externe Links/Dateien) automatisch mit aus; Gesamt-Ampel = schlechtestes Teilergebnis; EIN kombinierter Report + EIN Alert - neuer Config-Abschnitt periodic_checks (Default: wöchentlich, an) Klartext (scanner/plain.py): - Ampel 🟢/🟡/🔴 + Sätze ohne Fachbegriffe, aus den strukturierten Befunden - Report: Klartext oben, "Technische Details (für Ihren Dienstleister)" unten - Terminal-Ausgabe und E-Mail (alerter.py) ebenso umgestellt Komfort: - approve --all/--rebuild legt die Datei-Überwachung automatisch mit an (Opt-out: --skip-assets); approve-assets bleibt für den Sonderfall - status zeigt Ampel zuoberst + wann die Wochen-Prüfungen zuletzt liefen - Onboarding-Text in einfacher Sprache, wenn noch kein Vergleichsstand existiert Doku: README + BEDIENUNGSANLEITUNG mit "In 3 Schritten"-Einstieg. Tests: test_state.py, test_plain.py (Fachbegriff-Assertion), Auto-Asset- Baseline-Test. 165 Tests grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
54ff1a6060
commit
bed20db8bd
11 changed files with 856 additions and 202 deletions
|
|
@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
|
|||
import pytest
|
||||
import requests
|
||||
|
||||
from scanner.baseline import BaselineManager
|
||||
from scanner.checker import fetch_asset_hashes
|
||||
from scanner.differ import compare_asset_hashes, score_asset_diff
|
||||
|
||||
|
|
@ -142,3 +143,43 @@ class TestScoreAssetDiff:
|
|||
assert result["score"] == 80
|
||||
assert result["level"] == "red"
|
||||
assert result["exit_code"] == 2
|
||||
|
||||
|
||||
class TestAutoAssetBaselineOnApprove:
|
||||
"""approve --all soll die Datei-Baseline automatisch mit anlegen."""
|
||||
|
||||
def test_establish_asset_baseline_saves_hashes(self, tmp_path):
|
||||
from scanner.__main__ import _establish_asset_baseline
|
||||
|
||||
bm = BaselineManager(tmp_path)
|
||||
page = {
|
||||
"url": "https://x.de/",
|
||||
"status": 200,
|
||||
"links": {"script": [{"url": "https://x.de/app.js", "class": "internal"}],
|
||||
"link_rel": [], "img": []},
|
||||
}
|
||||
snap_dir = bm.save_snapshot([page])
|
||||
|
||||
with patch("scanner.__main__.fetch_asset_hashes") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"https://x.de/app.js": {"sha256": "abc123", "size": 10, "error": None}
|
||||
}
|
||||
_establish_asset_baseline({"request_timeout": 15}, bm, snap_dir)
|
||||
|
||||
saved = bm.load_asset_hashes()
|
||||
assert "https://x.de/app.js" in saved
|
||||
assert saved["https://x.de/app.js"]["sha256"] == "abc123"
|
||||
|
||||
def test_no_assets_writes_nothing(self, tmp_path):
|
||||
from scanner.__main__ import _establish_asset_baseline
|
||||
|
||||
bm = BaselineManager(tmp_path)
|
||||
page = {"url": "https://x.de/", "status": 200,
|
||||
"links": {"script": [], "link_rel": [], "img": []}}
|
||||
snap_dir = bm.save_snapshot([page])
|
||||
|
||||
with patch("scanner.__main__.fetch_asset_hashes") as mock_fetch:
|
||||
_establish_asset_baseline({"request_timeout": 15}, bm, snap_dir)
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
assert bm.load_asset_hashes() == {}
|
||||
|
|
|
|||
85
tests/test_plain.py
Normal file
85
tests/test_plain.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Tests für die Klartext-Schicht (laienverständliche Ausgabe)."""
|
||||
import pytest
|
||||
|
||||
from scanner.plain import ampel, ampel_zeile, klartext_befunde, was_tun
|
||||
|
||||
# Fachbegriffe, die in der Klartext-Ausgabe NICHT vorkommen dürfen
|
||||
JARGON = ["Score", "Canonical", "JSON-LD", "Meta-Refresh", "Hash", "SHA-256",
|
||||
"Whitelist", "Hidden Content", "Exit-Code"]
|
||||
|
||||
|
||||
def _no_jargon(saetze):
|
||||
text = " ".join(saetze)
|
||||
treffer = [w for w in JARGON if w.lower() in text.lower()]
|
||||
assert not treffer, f"Fachbegriff(e) in Klartext gefunden: {treffer}"
|
||||
|
||||
|
||||
class TestAmpel:
|
||||
@pytest.mark.parametrize("level", ["green", "yellow", "red"])
|
||||
def test_each_level_has_symbol_and_sentence(self, level):
|
||||
symbol, satz = ampel(level)
|
||||
assert symbol
|
||||
assert satz.endswith(".") or satz.endswith(")")
|
||||
|
||||
def test_unknown_level_safe(self):
|
||||
symbol, satz = ampel("bogus")
|
||||
assert symbol and satz
|
||||
|
||||
def test_ampel_zeile_combines(self):
|
||||
zeile = ampel_zeile("green")
|
||||
assert zeile.startswith("🟢")
|
||||
|
||||
|
||||
class TestKlartextBefunde:
|
||||
def test_empty_diff_says_all_good(self):
|
||||
saetze = klartext_befunde({})
|
||||
assert any("nichts" in s.lower() for s in saetze)
|
||||
_no_jargon(saetze)
|
||||
|
||||
def test_new_external_domain_plain(self):
|
||||
saetze = klartext_befunde({"new_external_domains": ["spam.example.com"]})
|
||||
assert any("fremde" in s.lower() or "adresse" in s.lower() for s in saetze)
|
||||
_no_jargon(saetze)
|
||||
|
||||
def test_hidden_content_plain(self):
|
||||
diff = {"page_diffs": [{"url": "x", "new_hidden_content": [{"type": "x"}]}]}
|
||||
saetze = klartext_befunde(diff)
|
||||
assert any("unsichtbar" in s.lower() for s in saetze)
|
||||
_no_jargon(saetze)
|
||||
|
||||
def test_suspicious_filename_is_serious(self):
|
||||
saetze = klartext_befunde({"suspicious_filenames": [{"page": "x", "url": "shell.php"}]})
|
||||
assert any("warnzeichen" in s.lower() or "verdächtig" in s.lower() for s in saetze)
|
||||
_no_jargon(saetze)
|
||||
|
||||
def test_cloaking_extra_link_plain(self):
|
||||
cloak = {"findings": [{"url": "x", "extra_links": ["https://spam.com/x"], "extra_text_chars": 0}]}
|
||||
saetze = klartext_befunde({}, cloak_diff=cloak)
|
||||
assert any("google" in s.lower() for s in saetze)
|
||||
_no_jargon(saetze)
|
||||
|
||||
def test_asset_change_plain(self):
|
||||
asset = {"changed": [{"url": "x", "type": "script"}]}
|
||||
saetze = klartext_befunde({}, asset_diff=asset)
|
||||
assert any("datei" in s.lower() for s in saetze)
|
||||
_no_jargon(saetze)
|
||||
|
||||
def test_broken_ext_links_plain(self):
|
||||
ext = {"broken": 3}
|
||||
saetze = klartext_befunde({}, ext_links=ext)
|
||||
assert any("funktionieren" in s.lower() or "website" in s.lower() for s in saetze)
|
||||
_no_jargon(saetze)
|
||||
|
||||
|
||||
class TestWasTun:
|
||||
def test_red_has_urgent_steps(self):
|
||||
schritte = was_tun("red")
|
||||
assert any("betreut" in s.lower() or "informieren" in s.lower() for s in schritte)
|
||||
|
||||
def test_green_needs_nothing(self):
|
||||
schritte = was_tun("green")
|
||||
assert any("nichts" in s.lower() for s in schritte)
|
||||
|
||||
def test_no_jargon_anywhere(self):
|
||||
for lvl in ("green", "yellow", "red"):
|
||||
_no_jargon(was_tun(lvl))
|
||||
47
tests/test_state.py
Normal file
47
tests/test_state.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Tests für die Zustandsdatei, die die Wochen-Prüfungen steuert."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from scanner.baseline import BaselineManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bm(tmp_path):
|
||||
return BaselineManager(tmp_path)
|
||||
|
||||
|
||||
class TestRunState:
|
||||
def test_never_run_is_due(self, bm):
|
||||
assert bm.is_check_due("cloak_check", 7) is True
|
||||
|
||||
def test_just_run_is_not_due(self, bm):
|
||||
bm.mark_check_run("cloak_check")
|
||||
assert bm.is_check_due("cloak_check", 7) is False
|
||||
|
||||
def test_old_run_is_due(self, bm):
|
||||
# Lauf vor 10 Tagen → bei Intervall 7 fällig
|
||||
old = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
|
||||
bm.save_state({"last_runs": {"assets": old}})
|
||||
assert bm.is_check_due("assets", 7) is True
|
||||
|
||||
def test_recent_run_within_interval_not_due(self, bm):
|
||||
recent = (datetime.now(timezone.utc) - timedelta(days=3)).isoformat()
|
||||
bm.save_state({"last_runs": {"assets": recent}})
|
||||
assert bm.is_check_due("assets", 7) is False
|
||||
|
||||
def test_save_load_roundtrip(self, bm):
|
||||
bm.mark_check_run("ext_links")
|
||||
state = bm.load_state()
|
||||
assert "ext_links" in state["last_runs"]
|
||||
|
||||
def test_corrupt_state_file_recovers(self, bm):
|
||||
bm._state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
bm._state_path.write_text("{ not valid json", encoding="utf-8")
|
||||
# Darf nicht crashen, sondern als "nie gelaufen" gelten
|
||||
assert bm.load_state() == {"last_runs": {}}
|
||||
assert bm.is_check_due("cloak_check", 7) is True
|
||||
|
||||
def test_invalid_timestamp_is_due(self, bm):
|
||||
bm.save_state({"last_runs": {"assets": "kein-datum"}})
|
||||
assert bm.is_check_due("assets", 7) is True
|
||||
Loading…
Add table
Add a link
Reference in a new issue