Previously, broken links that were part of the baseline ("known broken")
disappeared from the terminal output (score=0, no Klartext mention).
- differ.py: build reverse link index to record which pages link to
each broken URL; "pages" field added to new_broken/known_broken entries
- plain.py: klartext_befunde() now includes known_broken_urls alongside
new_broken_urls — broken links are always reported until fixed
- __main__.py: report shows "gefunden auf: <page>" for every broken entry,
known entries labelled "[bekannt, noch nicht behoben]"
- 3 new tests (169 total)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
376 lines
15 KiB
Python
376 lines
15 KiB
Python
"""Tests for diff logic and scoring."""
|
|
import pytest
|
|
from scanner.differ import (
|
|
compare_snapshots,
|
|
diff_link_set,
|
|
diff_metadata,
|
|
diff_text,
|
|
normalize_text,
|
|
score_diff,
|
|
text_hash,
|
|
)
|
|
|
|
BASE_CFG = {
|
|
"scoring": {
|
|
"new_external_domain": 50,
|
|
"new_internal_url": 30,
|
|
"missing_internal_url": 20,
|
|
"hidden_content": 40,
|
|
"unexpected_canonical": 35,
|
|
"meta_refresh": 40,
|
|
"unexpected_jsonld": 35,
|
|
"large_text_addition": 20,
|
|
"new_inline_script_suspicious": 30,
|
|
"comment_links": 25,
|
|
"noscript_links": 25,
|
|
"missing_security_header": 5,
|
|
"suspicious_content_pattern": 50,
|
|
},
|
|
"thresholds": {"yellow": 20, "red": 60, "large_text_block_chars": 200},
|
|
"normalization": {"collapse_whitespace": True, "ignore_patterns": []},
|
|
}
|
|
|
|
|
|
class TestNormalizeText:
|
|
def test_collapses_whitespace(self):
|
|
result = normalize_text("Hello World\n\nFoo", BASE_CFG)
|
|
assert result == "Hello World Foo"
|
|
|
|
def test_applies_ignore_patterns(self):
|
|
cfg = dict(BASE_CFG)
|
|
cfg["normalization"] = {
|
|
"collapse_whitespace": True,
|
|
"ignore_patterns": [r"\d{2}\.\d{2}\.\d{4}"],
|
|
}
|
|
result = normalize_text("Stand: 12.06.2026 Text", cfg)
|
|
assert "[IGNORED]" in result
|
|
assert "12.06.2026" not in result
|
|
|
|
|
|
class TestDiffText:
|
|
def test_identical_texts_produce_empty_diff(self):
|
|
assert diff_text("Hello", "Hello") == []
|
|
|
|
def test_addition_shows_plus_line(self):
|
|
lines = diff_text("Hello", "Hello\nWorld")
|
|
assert any(l.startswith("+") and "World" in l for l in lines)
|
|
|
|
def test_removal_shows_minus_line(self):
|
|
lines = diff_text("Hello\nWorld", "Hello")
|
|
assert any(l.startswith("-") and "World" in l for l in lines)
|
|
|
|
|
|
class TestDiffLinkSet:
|
|
def test_detects_added_link(self):
|
|
old = [{"url": "https://example.com/a"}]
|
|
new = [{"url": "https://example.com/a"}, {"url": "https://example.com/b"}]
|
|
result = diff_link_set(old, new)
|
|
assert "https://example.com/b" in result["added"]
|
|
assert result["removed"] == []
|
|
|
|
def test_detects_removed_link(self):
|
|
old = [{"url": "https://example.com/a"}, {"url": "https://example.com/b"}]
|
|
new = [{"url": "https://example.com/a"}]
|
|
result = diff_link_set(old, new)
|
|
assert "https://example.com/b" in result["removed"]
|
|
|
|
def test_no_change(self):
|
|
links = [{"url": "https://example.com/a"}]
|
|
result = diff_link_set(links, links)
|
|
assert result == {"added": [], "removed": []}
|
|
|
|
def test_truly_new_url_detected(self):
|
|
# Cache-buster stripping happens at extraction time; diff_link_set is a
|
|
# plain set comparison on already-normalised URLs.
|
|
old = [{"url": "https://example.com/style.css"}]
|
|
new = [{"url": "https://example.com/style.css"},
|
|
{"url": "https://example.com/extra.js"}]
|
|
result = diff_link_set(old, new)
|
|
assert "https://example.com/extra.js" in result["added"]
|
|
|
|
|
|
class TestDiffMetadata:
|
|
def test_detects_title_change(self):
|
|
old = {"title": "Alte Seite", "h1": [], "h2": []}
|
|
new = {"title": "Neue Seite", "h1": [], "h2": []}
|
|
result = diff_metadata(old, new)
|
|
assert "title" in result
|
|
assert result["title"]["old"] == "Alte Seite"
|
|
|
|
def test_no_change(self):
|
|
meta = {"title": "Seite", "description": "Desc", "h1": [], "h2": []}
|
|
assert diff_metadata(meta, meta) == {}
|
|
|
|
def test_detects_canonical_change(self):
|
|
old = {"canonical": "https://bredelar.info/", "h1": [], "h2": []}
|
|
new = {"canonical": "https://spam.example.com/", "h1": [], "h2": []}
|
|
result = diff_metadata(old, new)
|
|
assert "canonical" in result
|
|
|
|
|
|
class TestCompareSnapshots:
|
|
def _make_page(self, url, text="Inhalt", links=None, hidden=None):
|
|
return {
|
|
"url": url,
|
|
"text": text,
|
|
"links": links or {"a": [], "script": [], "iframe": [], "link_rel": [], "form": [], "meta_refresh": [], "canonical": None},
|
|
"metadata": {"title": "T", "h1": [], "h2": []},
|
|
"hidden_content": hidden or [],
|
|
"inline_scripts": [],
|
|
"jsonld": [],
|
|
"comment_links": [],
|
|
}
|
|
|
|
def test_new_url_detected(self):
|
|
baseline = {"https://b.info/": self._make_page("https://b.info/")}
|
|
current = {
|
|
"https://b.info/": self._make_page("https://b.info/"),
|
|
"https://b.info/new/": self._make_page("https://b.info/new/"),
|
|
}
|
|
result = compare_snapshots(baseline, current, BASE_CFG)
|
|
assert "https://b.info/new/" in result["new_internal_urls"]
|
|
|
|
def test_missing_url_detected(self):
|
|
baseline = {
|
|
"https://b.info/": self._make_page("https://b.info/"),
|
|
"https://b.info/old/": self._make_page("https://b.info/old/"),
|
|
}
|
|
current = {"https://b.info/": self._make_page("https://b.info/")}
|
|
result = compare_snapshots(baseline, current, BASE_CFG)
|
|
assert "https://b.info/old/" in result["missing_internal_urls"]
|
|
|
|
def test_unchanged_page_not_in_diffs(self):
|
|
page = self._make_page("https://b.info/", "Gleicher Inhalt")
|
|
result = compare_snapshots({"https://b.info/": page}, {"https://b.info/": page}, BASE_CFG)
|
|
assert result["changed_pages"] == 0
|
|
|
|
def test_text_change_detected(self):
|
|
old = self._make_page("https://b.info/", "Alter Text")
|
|
new = self._make_page("https://b.info/", "Alter Text plus neuer Spam-Block im SEO-Kontext")
|
|
result = compare_snapshots({"https://b.info/": old}, {"https://b.info/": new}, BASE_CFG)
|
|
assert result["changed_pages"] == 1
|
|
|
|
def test_new_external_domain_detected(self):
|
|
def _page_with_ext(url, ext_url):
|
|
p = self._make_page(url)
|
|
p["links"]["a"] = [{"url": ext_url, "class": "external", "text": "x", "rel": ""}]
|
|
return p
|
|
|
|
baseline = {"https://b.info/": self._make_page("https://b.info/")}
|
|
current = {"https://b.info/": _page_with_ext("https://b.info/", "https://spam.example.com/")}
|
|
result = compare_snapshots(baseline, current, BASE_CFG)
|
|
assert "spam.example.com" in result["new_external_domains"]
|
|
|
|
|
|
class TestUnexpectedJsonLd:
|
|
CFG = {**BASE_CFG, "allowed_jsonld_types": ["Organization", "WebPage"]}
|
|
|
|
def _page(self, url, jsonld):
|
|
return {
|
|
"url": url, "status": 200, "text": "Inhalt",
|
|
"links": {"a": [], "script": [], "iframe": [], "link_rel": [], "form": [], "meta_refresh": [], "canonical": None},
|
|
"metadata": {"title": "T", "h1": [], "h2": []},
|
|
"hidden_content": [], "inline_scripts": [], "jsonld": jsonld, "comment_links": [],
|
|
}
|
|
|
|
def test_unexpected_type_flagged_without_page_diff(self):
|
|
# Page is otherwise unchanged (same text/links) but carries a bad @type.
|
|
base = {"https://b.info/": self._page("https://b.info/", [{"type": "Organization"}])}
|
|
cur = {"https://b.info/": self._page("https://b.info/", [{"type": "Pharmacy"}])}
|
|
result = compare_snapshots(base, cur, self.CFG)
|
|
types = [e["type"] for e in result["unexpected_jsonld"]]
|
|
assert "Pharmacy" in types
|
|
|
|
def test_allowed_type_not_flagged(self):
|
|
base = {"https://b.info/": self._page("https://b.info/", [])}
|
|
cur = {"https://b.info/": self._page("https://b.info/", [{"type": "WebPage"}])}
|
|
result = compare_snapshots(base, cur, self.CFG)
|
|
assert result["unexpected_jsonld"] == []
|
|
|
|
def test_preexisting_unexpected_type_not_realerted(self):
|
|
# Bad type already in baseline → approved → should not re-alert.
|
|
base = {"https://b.info/": self._page("https://b.info/", [{"type": "Pharmacy"}])}
|
|
cur = {"https://b.info/": self._page("https://b.info/", [{"type": "Pharmacy"}])}
|
|
result = compare_snapshots(base, cur, self.CFG)
|
|
assert result["unexpected_jsonld"] == []
|
|
|
|
def test_unexpected_type_scores(self):
|
|
result = score_diff({
|
|
"new_internal_urls": [], "missing_internal_urls": [],
|
|
"new_external_domains": [], "page_diffs": [], "new_pages_analysis": [],
|
|
"unexpected_jsonld": [{"url": "https://b.info/", "type": "Pharmacy"}],
|
|
}, BASE_CFG)
|
|
assert result["score"] == 35
|
|
assert result["level"] == "yellow"
|
|
|
|
|
|
class TestSuspiciousFilenames:
|
|
def test_suspicious_filename_scores_red(self):
|
|
result = score_diff({
|
|
"new_internal_urls": [], "missing_internal_urls": [],
|
|
"new_external_domains": [], "page_diffs": [], "new_pages_analysis": [],
|
|
"suspicious_filenames": [{"page": "https://b.info/", "url": "https://b.info/shell.php"}],
|
|
}, BASE_CFG)
|
|
assert result["score"] == 60
|
|
assert result["level"] == "red"
|
|
|
|
|
|
class TestBrokenLinks:
|
|
def _make_page(self, url, text="Inhalt", status=200):
|
|
return {
|
|
"url": url, "status": status, "text": text,
|
|
"links": {"a": [], "script": [], "iframe": [], "link_rel": [], "form": [], "meta_refresh": [], "canonical": None},
|
|
"metadata": {"title": "T", "h1": [], "h2": []},
|
|
"hidden_content": [], "inline_scripts": [], "jsonld": [], "comment_links": [],
|
|
}
|
|
|
|
def test_new_broken_url_detected(self):
|
|
baseline = {"https://b.info/": self._make_page("https://b.info/")}
|
|
current = {
|
|
"https://b.info/": self._make_page("https://b.info/"),
|
|
"https://b.info/missing/": self._make_page("https://b.info/missing/", status=404),
|
|
}
|
|
result = compare_snapshots(baseline, current, BASE_CFG)
|
|
assert len(result["new_broken_urls"]) == 1
|
|
assert result["new_broken_urls"][0]["url"] == "https://b.info/missing/"
|
|
assert result["new_broken_urls"][0]["status"] == 404
|
|
assert "pages" in result["new_broken_urls"][0]
|
|
|
|
def test_broken_url_includes_source_pages(self):
|
|
"""broken entry lists the pages that link to the broken URL."""
|
|
home = self._make_page("https://b.info/")
|
|
home["links"]["a"] = [{"url": "https://b.info/missing/", "class": "internal"}]
|
|
baseline = {"https://b.info/": self._make_page("https://b.info/")}
|
|
current = {
|
|
"https://b.info/": home,
|
|
"https://b.info/missing/": self._make_page("https://b.info/missing/", status=404),
|
|
}
|
|
result = compare_snapshots(baseline, current, BASE_CFG)
|
|
entry = result["new_broken_urls"][0]
|
|
assert "https://b.info/" in entry["pages"]
|
|
|
|
def test_known_broken_not_in_new(self):
|
|
broken_page = {"https://b.info/old/": self._make_page("https://b.info/old/", status=404)}
|
|
baseline = {**{"https://b.info/": self._make_page("https://b.info/")}, **broken_page}
|
|
current = {**{"https://b.info/": self._make_page("https://b.info/")}, **broken_page}
|
|
result = compare_snapshots(baseline, current, BASE_CFG)
|
|
assert result["new_broken_urls"] == []
|
|
assert len(result["known_broken_urls"]) == 1
|
|
assert "pages" in result["known_broken_urls"][0]
|
|
|
|
def test_broken_url_scores(self):
|
|
result = score_diff({
|
|
"new_internal_urls": [], "missing_internal_urls": [],
|
|
"new_external_domains": [], "page_diffs": [], "new_pages_analysis": [],
|
|
"new_broken_urls": [{"url": "https://b.info/gone/", "status": 404}],
|
|
"known_broken_urls": [],
|
|
}, BASE_CFG)
|
|
assert result["score"] == 20
|
|
assert result["level"] == "yellow"
|
|
|
|
def test_known_broken_not_scored(self):
|
|
result = score_diff({
|
|
"new_internal_urls": [], "missing_internal_urls": [],
|
|
"new_external_domains": [], "page_diffs": [], "new_pages_analysis": [],
|
|
"new_broken_urls": [],
|
|
"known_broken_urls": [{"url": "https://b.info/old/", "status": 404}],
|
|
}, BASE_CFG)
|
|
assert result["score"] == 0
|
|
assert result["level"] == "green"
|
|
|
|
|
|
class TestScoreDiff:
|
|
def test_green_for_no_changes(self):
|
|
result = score_diff({
|
|
"new_internal_urls": [],
|
|
"missing_internal_urls": [],
|
|
"new_external_domains": [],
|
|
"page_diffs": [],
|
|
"new_pages_analysis": [],
|
|
}, BASE_CFG)
|
|
assert result["level"] == "green"
|
|
assert result["exit_code"] == 0
|
|
|
|
def test_yellow_for_new_url(self):
|
|
result = score_diff({
|
|
"new_internal_urls": ["https://b.info/new/"],
|
|
"missing_internal_urls": [],
|
|
"new_external_domains": [],
|
|
"page_diffs": [],
|
|
"new_pages_analysis": [],
|
|
}, BASE_CFG)
|
|
assert result["level"] == "yellow"
|
|
assert result["exit_code"] == 1
|
|
|
|
def test_yellow_for_single_new_external_domain(self):
|
|
# One new external domain = 50 pts → yellow (red threshold is 60)
|
|
result = score_diff({
|
|
"new_internal_urls": [],
|
|
"missing_internal_urls": [],
|
|
"new_external_domains": ["pharmacy.example.com"],
|
|
"page_diffs": [],
|
|
"new_pages_analysis": [],
|
|
}, BASE_CFG)
|
|
assert result["level"] == "yellow"
|
|
assert result["exit_code"] == 1
|
|
|
|
def test_red_for_new_external_domain_plus_hidden_content(self):
|
|
# External domain (50) + hidden content (40) = 90 pts → red
|
|
result = score_diff({
|
|
"new_internal_urls": [],
|
|
"missing_internal_urls": [],
|
|
"new_external_domains": ["pharmacy.example.com"],
|
|
"page_diffs": [{
|
|
"url": "https://b.info/",
|
|
"text_diff": [],
|
|
"added_chars": 0,
|
|
"link_diff": {},
|
|
"meta_diff": {},
|
|
"new_hidden_content": [{"type": "inline_style_hidden", "indicators": ["display:none"]}],
|
|
"new_inline_scripts": [],
|
|
"new_jsonld": [],
|
|
"new_meta_refresh": [],
|
|
"canonical_changed": False,
|
|
"new_comment_links": [],
|
|
"old_canonical": None,
|
|
"new_canonical": None,
|
|
}],
|
|
"new_pages_analysis": [],
|
|
}, BASE_CFG)
|
|
assert result["level"] == "red"
|
|
assert result["exit_code"] == 2
|
|
|
|
def test_yellow_for_hidden_content_alone(self):
|
|
# One hidden content finding = 40 pts → yellow (below red threshold of 60)
|
|
result = score_diff({
|
|
"new_internal_urls": [],
|
|
"missing_internal_urls": [],
|
|
"new_external_domains": [],
|
|
"page_diffs": [{
|
|
"url": "https://b.info/",
|
|
"text_diff": [],
|
|
"added_chars": 0,
|
|
"link_diff": {},
|
|
"meta_diff": {},
|
|
"new_hidden_content": [{"type": "inline_style_hidden", "indicators": ["display:none"]}],
|
|
"new_inline_scripts": [],
|
|
"new_jsonld": [],
|
|
"new_meta_refresh": [],
|
|
"canonical_changed": False,
|
|
"new_comment_links": [],
|
|
"old_canonical": None,
|
|
"new_canonical": None,
|
|
}],
|
|
"new_pages_analysis": [],
|
|
}, BASE_CFG)
|
|
assert result["level"] == "yellow"
|
|
|
|
def test_reasons_include_domain(self):
|
|
result = score_diff({
|
|
"new_internal_urls": [],
|
|
"missing_internal_urls": [],
|
|
"new_external_domains": ["spam.com"],
|
|
"page_diffs": [],
|
|
"new_pages_analysis": [],
|
|
}, BASE_CFG)
|
|
assert any("spam.com" in r for r in result["reasons"])
|