feat: initial implementation of website integrity scanner
Python-Package zur unabhängigen Überwachung statischer Websites gegen SEO-Spam und unbefugte Manipulationen. Läuft außerhalb des Hosters. Kernfunktionen: - Reiner Python-Crawler (bs4+lxml), kein wget - Baseline-Management mit manuellem Freigabe-Workflow (niemals automatisch) - Text-/Link-/Meta-Diff mit Risiko-Scoring (grün/gelb/rot) - Hidden-Content-Erkennung (CSS inline, noscript, Kommentar-Links) - Whitelist für externe Domains und interne Pfade - E-Mail + Webhook Alarmierung - CLI: init, crawl, check, scan, approve, report, status - 79 Unit-Tests (pytest), alle grün Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
98e8d11eb5
24 changed files with 4295 additions and 0 deletions
161
tests/test_baseline.py
Normal file
161
tests/test_baseline.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Tests for snapshot saving, baseline management and approval workflow."""
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from scanner.baseline import BaselineManager, url_key
|
||||
|
||||
|
||||
def _sample_page(url: str, text: str = "Inhalt") -> dict:
|
||||
return {
|
||||
"url": url,
|
||||
"final_url": url,
|
||||
"status": 200,
|
||||
"content_type": "text/html",
|
||||
"response_headers": {},
|
||||
"text": text,
|
||||
"links": {"a": [], "canonical": None, "meta_refresh": []},
|
||||
"metadata": {"title": "T", "h1": [], "h2": []},
|
||||
"hidden_content": [],
|
||||
"inline_scripts": [],
|
||||
"jsonld": [],
|
||||
"comment_links": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bm(tmp_path: Path) -> BaselineManager:
|
||||
return BaselineManager(tmp_path / "data")
|
||||
|
||||
|
||||
class TestUrlKey:
|
||||
def test_deterministic(self):
|
||||
assert url_key("https://example.com/") == url_key("https://example.com/")
|
||||
|
||||
def test_different_urls_different_keys(self):
|
||||
assert url_key("https://a.com/") != url_key("https://b.com/")
|
||||
|
||||
def test_16_hex_chars(self):
|
||||
k = url_key("https://example.com/")
|
||||
assert len(k) == 16
|
||||
assert all(c in "0123456789abcdef" for c in k)
|
||||
|
||||
|
||||
class TestSaveSnapshot:
|
||||
def test_creates_manifest(self, bm, tmp_path):
|
||||
pages = [_sample_page("https://b.info/")]
|
||||
snap_dir = bm.save_snapshot(pages)
|
||||
assert (snap_dir / "manifest.json").exists()
|
||||
|
||||
def test_manifest_content(self, bm):
|
||||
pages = [_sample_page("https://b.info/"), _sample_page("https://b.info/p/")]
|
||||
snap_dir = bm.save_snapshot(pages)
|
||||
manifest = json.loads((snap_dir / "manifest.json").read_text())
|
||||
assert manifest["page_count"] == 2
|
||||
assert "https://b.info/" in manifest["urls"]
|
||||
|
||||
def test_creates_page_files(self, bm):
|
||||
pages = [_sample_page("https://b.info/")]
|
||||
snap_dir = bm.save_snapshot(pages)
|
||||
page_files = list((snap_dir / "pages").glob("*.json"))
|
||||
assert len(page_files) == 1
|
||||
|
||||
def test_latest_snapshot_dir(self, bm):
|
||||
assert bm.latest_snapshot_dir() is None
|
||||
bm.save_snapshot([_sample_page("https://b.info/")])
|
||||
assert bm.latest_snapshot_dir() is not None
|
||||
|
||||
|
||||
class TestLoadSnapshot:
|
||||
def test_loads_saved_snapshot(self, bm):
|
||||
pages = [_sample_page("https://b.info/"), _sample_page("https://b.info/p/")]
|
||||
bm.save_snapshot(pages)
|
||||
snap = bm.load_snapshot()
|
||||
assert "https://b.info/" in snap["pages"]
|
||||
assert "https://b.info/p/" in snap["pages"]
|
||||
|
||||
def test_returns_empty_when_no_snapshot(self, bm):
|
||||
result = bm.load_snapshot()
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestBaselineExists:
|
||||
def test_false_before_approve(self, bm):
|
||||
assert not bm.baseline_exists()
|
||||
|
||||
def test_true_after_approve(self, bm):
|
||||
bm.save_snapshot([_sample_page("https://b.info/")])
|
||||
bm.approve_all(note="test")
|
||||
assert bm.baseline_exists()
|
||||
|
||||
|
||||
class TestApproveAll:
|
||||
def test_approve_all_creates_baseline(self, bm):
|
||||
pages = [_sample_page("https://b.info/"), _sample_page("https://b.info/p/")]
|
||||
bm.save_snapshot(pages)
|
||||
approved = bm.approve_all(note="Initial baseline")
|
||||
assert "https://b.info/" in approved
|
||||
assert "https://b.info/p/" in approved
|
||||
assert bm.baseline_exists()
|
||||
|
||||
def test_manifest_contains_approval_metadata(self, bm):
|
||||
bm.save_snapshot([_sample_page("https://b.info/")])
|
||||
bm.approve_all(note="test note", approved_by="testuser")
|
||||
manifest = json.loads((bm.baseline_dir / "manifest.json").read_text())
|
||||
assert manifest["note"] == "test note"
|
||||
assert manifest["approved_by"] == "testuser"
|
||||
assert "approved_at" in manifest
|
||||
|
||||
def test_approve_all_raises_without_snapshot(self, bm):
|
||||
with pytest.raises(RuntimeError):
|
||||
bm.approve_all()
|
||||
|
||||
|
||||
class TestApproveUrl:
|
||||
def test_approve_single_url(self, bm):
|
||||
pages = [_sample_page("https://b.info/"), _sample_page("https://b.info/p/")]
|
||||
bm.save_snapshot(pages)
|
||||
result = bm.approve_url("https://b.info/")
|
||||
assert result is True
|
||||
assert bm.baseline_exists()
|
||||
baseline = bm.load_baseline()
|
||||
assert "https://b.info/" in baseline["pages"]
|
||||
assert "https://b.info/p/" not in baseline["pages"]
|
||||
|
||||
def test_approve_missing_url_returns_false(self, bm):
|
||||
bm.save_snapshot([_sample_page("https://b.info/")])
|
||||
result = bm.approve_url("https://b.info/does-not-exist/")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestLoadBaseline:
|
||||
def test_loads_approved_pages(self, bm):
|
||||
pages = [_sample_page("https://b.info/", "Text A")]
|
||||
bm.save_snapshot(pages)
|
||||
bm.approve_all()
|
||||
baseline = bm.load_baseline()
|
||||
assert "https://b.info/" in baseline["pages"]
|
||||
assert baseline["pages"]["https://b.info/"]["text"] == "Text A"
|
||||
|
||||
def test_never_auto_updates(self, bm):
|
||||
"""Baseline must not change unless approve is explicitly called."""
|
||||
bm.save_snapshot([_sample_page("https://b.info/", "Original")])
|
||||
bm.approve_all()
|
||||
|
||||
# Second snapshot with different content
|
||||
bm.save_snapshot([_sample_page("https://b.info/", "Manipuliert")])
|
||||
|
||||
# Baseline must still contain original content
|
||||
baseline = bm.load_baseline()
|
||||
assert baseline["pages"]["https://b.info/"]["text"] == "Original"
|
||||
|
||||
|
||||
class TestRebuildBaseline:
|
||||
def test_rebuild_replaces_baseline(self, bm):
|
||||
bm.save_snapshot([_sample_page("https://b.info/", "Alt")])
|
||||
bm.approve_all()
|
||||
|
||||
bm.save_snapshot([_sample_page("https://b.info/", "Neu")])
|
||||
bm.rebuild_baseline(note="Major update")
|
||||
|
||||
baseline = bm.load_baseline()
|
||||
assert baseline["pages"]["https://b.info/"]["text"] == "Neu"
|
||||
Loading…
Add table
Add a link
Reference in a new issue