2026-06-12 09:26:59 +02:00
|
|
|
"""Tests for asset hashing and comparison."""
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import requests
|
|
|
|
|
|
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>
2026-06-12 11:14:05 +02:00
|
|
|
from scanner.baseline import BaselineManager
|
2026-06-12 09:26:59 +02:00
|
|
|
from scanner.checker import fetch_asset_hashes
|
|
|
|
|
from scanner.differ import compare_asset_hashes, score_asset_diff
|
|
|
|
|
|
|
|
|
|
CFG = {"scoring": {}, "thresholds": {}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# fetch_asset_hashes
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class TestFetchAssetHashes:
|
|
|
|
|
def _mock_session(self, status=200, content=b"data"):
|
|
|
|
|
resp = MagicMock()
|
|
|
|
|
resp.status_code = status
|
|
|
|
|
resp.iter_content.return_value = [content]
|
|
|
|
|
resp.close = MagicMock()
|
|
|
|
|
inst = MagicMock()
|
|
|
|
|
inst.get.return_value = resp
|
|
|
|
|
inst.close = MagicMock()
|
|
|
|
|
return inst
|
|
|
|
|
|
|
|
|
|
def test_successful_fetch_returns_sha256(self):
|
|
|
|
|
import hashlib
|
|
|
|
|
data = b"hello asset"
|
|
|
|
|
expected = hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
|
|
|
|
with patch("scanner.checker.requests.Session") as MockSession:
|
|
|
|
|
MockSession.return_value = self._mock_session(content=data)
|
|
|
|
|
results = fetch_asset_hashes(["https://x.de/app.js"], delay=0)
|
|
|
|
|
|
|
|
|
|
assert results["https://x.de/app.js"]["sha256"] == expected
|
|
|
|
|
assert results["https://x.de/app.js"]["error"] is None
|
|
|
|
|
|
|
|
|
|
def test_http_error_recorded(self):
|
|
|
|
|
with patch("scanner.checker.requests.Session") as MockSession:
|
|
|
|
|
MockSession.return_value = self._mock_session(status=404)
|
|
|
|
|
results = fetch_asset_hashes(["https://x.de/missing.js"], delay=0)
|
|
|
|
|
|
|
|
|
|
assert results["https://x.de/missing.js"]["sha256"] is None
|
|
|
|
|
assert "HTTP 404" in results["https://x.de/missing.js"]["error"]
|
|
|
|
|
|
|
|
|
|
def test_connection_error_recorded(self):
|
|
|
|
|
with patch("scanner.checker.requests.Session") as MockSession:
|
|
|
|
|
inst = MockSession.return_value
|
|
|
|
|
inst.get.side_effect = requests.exceptions.ConnectionError("refused")
|
|
|
|
|
inst.close = MagicMock()
|
|
|
|
|
results = fetch_asset_hashes(["https://unreachable.de/x.js"], delay=0)
|
|
|
|
|
|
|
|
|
|
assert results["https://unreachable.de/x.js"]["sha256"] is None
|
|
|
|
|
assert results["https://unreachable.de/x.js"]["error"] is not None
|
|
|
|
|
|
|
|
|
|
def test_empty_list_returns_empty(self):
|
|
|
|
|
with patch("scanner.checker.requests.Session"):
|
|
|
|
|
results = fetch_asset_hashes([], delay=0)
|
|
|
|
|
assert results == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# compare_asset_hashes
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class TestCompareAssetHashes:
|
|
|
|
|
def _h(self, sha, type_="img", size=100):
|
|
|
|
|
return {"sha256": sha, "size": size, "type": type_, "error": None}
|
|
|
|
|
|
|
|
|
|
def test_no_changes_empty_diff(self):
|
|
|
|
|
h = {"https://x.de/img.jpg": self._h("aaa")}
|
|
|
|
|
result = compare_asset_hashes(h, h)
|
|
|
|
|
assert result["changed"] == []
|
|
|
|
|
assert result["new"] == []
|
|
|
|
|
assert result["missing"] == []
|
|
|
|
|
|
|
|
|
|
def test_changed_hash_detected(self):
|
|
|
|
|
baseline = {"https://x.de/img.jpg": self._h("aaa111")}
|
|
|
|
|
current = {"https://x.de/img.jpg": self._h("bbb222")}
|
|
|
|
|
result = compare_asset_hashes(baseline, current)
|
|
|
|
|
assert len(result["changed"]) == 1
|
|
|
|
|
assert result["changed"][0]["url"] == "https://x.de/img.jpg"
|
|
|
|
|
|
|
|
|
|
def test_new_asset_detected(self):
|
|
|
|
|
baseline = {}
|
|
|
|
|
current = {"https://x.de/new.jpg": self._h("abc")}
|
|
|
|
|
result = compare_asset_hashes(baseline, current)
|
|
|
|
|
assert "https://x.de/new.jpg" in result["new"]
|
|
|
|
|
|
|
|
|
|
def test_missing_asset_detected(self):
|
|
|
|
|
baseline = {"https://x.de/old.jpg": self._h("abc")}
|
|
|
|
|
current = {}
|
|
|
|
|
result = compare_asset_hashes(baseline, current)
|
|
|
|
|
assert "https://x.de/old.jpg" in result["missing"]
|
|
|
|
|
|
|
|
|
|
def test_fetch_error_not_in_changed(self):
|
|
|
|
|
baseline = {"https://x.de/img.jpg": self._h("aaa")}
|
|
|
|
|
current = {"https://x.de/img.jpg": {"sha256": None, "size": None, "type": "img", "error": "timeout"}}
|
|
|
|
|
result = compare_asset_hashes(baseline, current)
|
|
|
|
|
assert result["changed"] == []
|
|
|
|
|
assert len(result["fetch_errors"]) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# score_asset_diff
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class TestScoreAssetDiff:
|
|
|
|
|
def test_no_changes_green(self):
|
|
|
|
|
result = score_asset_diff({"changed": [], "new": [], "missing": [], "fetch_errors": [], "total_checked": 5}, CFG)
|
|
|
|
|
assert result["level"] == "green"
|
|
|
|
|
assert result["score"] == 0
|
|
|
|
|
|
|
|
|
|
def test_changed_script_scores_40(self):
|
|
|
|
|
diff = {"changed": [{"url": "https://x.de/app.js", "type": "script", "old_sha256": "a…", "new_sha256": "b…", "size_diff": 0}],
|
|
|
|
|
"new": [], "missing": [], "fetch_errors": [], "total_checked": 1}
|
|
|
|
|
result = score_asset_diff(diff, CFG)
|
|
|
|
|
assert result["score"] == 40
|
|
|
|
|
assert result["level"] == "yellow"
|
|
|
|
|
|
|
|
|
|
def test_changed_stylesheet_scores_30(self):
|
|
|
|
|
diff = {"changed": [{"url": "https://x.de/style.css", "type": "link_rel", "old_sha256": "a…", "new_sha256": "b…", "size_diff": 0}],
|
|
|
|
|
"new": [], "missing": [], "fetch_errors": [], "total_checked": 1}
|
|
|
|
|
result = score_asset_diff(diff, CFG)
|
|
|
|
|
assert result["score"] == 30
|
|
|
|
|
assert result["level"] == "yellow"
|
|
|
|
|
|
|
|
|
|
def test_changed_image_scores_20(self):
|
|
|
|
|
diff = {"changed": [{"url": "https://x.de/logo.png", "type": "img", "old_sha256": "a…", "new_sha256": "b…", "size_diff": 100}],
|
|
|
|
|
"new": [], "missing": [], "fetch_errors": [], "total_checked": 1}
|
|
|
|
|
result = score_asset_diff(diff, CFG)
|
|
|
|
|
assert result["score"] == 20
|
|
|
|
|
assert result["level"] == "yellow"
|
|
|
|
|
|
|
|
|
|
def test_two_changed_scripts_red(self):
|
|
|
|
|
entry = {"url": "https://x.de/x.js", "type": "script", "old_sha256": "a…", "new_sha256": "b…", "size_diff": 0}
|
|
|
|
|
diff = {"changed": [entry, {**entry, "url": "https://x.de/y.js"}],
|
|
|
|
|
"new": [], "missing": [], "fetch_errors": [], "total_checked": 2}
|
|
|
|
|
result = score_asset_diff(diff, CFG)
|
|
|
|
|
assert result["score"] == 80
|
|
|
|
|
assert result["level"] == "red"
|
|
|
|
|
assert result["exit_code"] == 2
|
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>
2026-06-12 11:14:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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() == {}
|