d0e3621192
Mail Render V2 (compliance/services/mail_render_v2/) — 11-Modul-Subpackage
das einen einheitlichen Audit-Mail-Output erzeugt mit:
- Header + KPI-Kacheln (Score / Findings / Docs / Vendors)
- TOC + Sprung-Links
- 3-Bucket-Trennung: Kritische Befunde / Manuelle Prüfung / Interne Reminder
- Cookie-Inventar (Name·Vendor·Kategorie·Speicherdauer·Löschfrist·Sitzland·Quelle·Status)
- Sofortmaßnahmen-Aggregator ("Sitzland ergänzen für 11 Cookies")
- 24 Legacy-Wrappers — alle alten build_*_html in V2-Sections
- Scope-Filter: FIN/GOV/MED/INS/EDU/LEG aus Berichten wenn nicht relevant
- Hint/Action-Dedup: keine doppelten Sätze pro Card mehr
Aktiviert via env MAIL_RENDER_V2=true (Default: legacy renderer).
5 neue deterministische Findings als Phase D-2b/B4/B5/B6/B7/B8:
B4 vendor_consistency_check — Cross-Doc-Provider-Widerspruch
(Elli: DSE nennt Vertex AI für Chatbot, /de/cookies nennt Iadvize → HIGH).
6 Service-Types: chatbot/analytics/tag_manager/pixel/cdn/cmp.
B5 ai_act_transparency_check — AI Act Art. 50 Transparenzpflicht
(Elli: Vertex AI vorhanden ohne Pre-Chat-Disclosure → HIGH).
Plus B5-Erweiterung: Rechtsgrundlage Art-6-Abs-1-lit-f bei AI → MED
(Einwilligung empfehlen).
B6 cross_doc_dpo_check — DPO in DSE genannt, nicht im Impressum (LOW).
B7 doc_staleness_check — Datum-Extraktion aus DSE/AGB/Nutzungsbedingungen.
Cap: AGB/NB 3y, DSE 2y. Älter → MEDIUM (Elli NB Stand 2018 → HIGH).
B8 cmp_fingerprint_check — Banner detected, aber CMP-Provider generic
(kein Usercentrics/OneTrust/Cookiebot/etc → MED).
B3-Erweiterung detect_intra_doc_contradictions — Widersprüchliche
Speicherdauer im SELBEN Doc (Elli: Logfile 7d vs 30d → HIGH).
LLM-Plausibility-Phase (Phase D-2b, finding_plausibility_check.py):
- Läuft AFTER MC pipeline, BEFORE D3 render
- Prompt mit Beispiel-IDs + 3-Phase-Mapping: exact-ID / position-fallback /
fuzzy-tail-match
- Stempelt llm_title / llm_severity / llm_recommendation / llm_drop auf
jeden FAIL CheckItem
- V2-Render zeigt "🤖 LLM-Plausibility:" Box pro Finding wenn gestempelt
- KNOWN ISSUE: qwen3:30b-a3b liefert oft empty content auf format='json' +
8000-char-excerpt prompts. Pipeline läuft mit stamped=0 weiter. Task #16.
Coverage gegen Elli Ground Truth (zeroclaw/docs/ground-truth/elli_eco_2026-06-06.json,
13 expected findings via WebFetch-Agent-Crawl):
- 4/4 HIGH-Findings ✓ (COOKIE-CONSENT-UX-001 + WIDERRUFSBELEHRUNG-001 +
VENDOR-CONSISTENCY-001 + AI-ACT-TRANSPARENCY-001)
- 4/6 MEDIUM ✓
- 2/3 LOW ✓
- Total: 10/13 = 77% (Sprung von 4/13 = 31%)
Restliche 3 Gaps als Task #17: IMPRESSUM-001 (multi-entity USt-IdNr),
TRANSFER-001 (Vendor-Mechanismus DPF/SCC), TH-RETENTION-002 (AI-Retention
pro Datenkategorie).
V2-Mail-Preview in Mailpit: 'v2all@local.test' Subject '[V2 ALL] ELLI'.
Backend healthy, B1+B3+B4+B5+B6+B7+B8 alle live im Orchestrator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.2 KiB
Python
214 lines
7.2 KiB
Python
"""Smoke tests for mail-render V2."""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
from compliance.services.mail_render_v2._compose import compose_v2
|
|
from compliance.services.mail_render_v2._cookie_inventory import (
|
|
build_cookie_inventory,
|
|
)
|
|
from compliance.services.mail_render_v2._style import (
|
|
card, chip, kpi_row, page_close, page_open, section, table,
|
|
)
|
|
|
|
|
|
def _mock_check_result(label, doc_type, url, comp, corr, n_checks, n_fail):
|
|
r = MagicMock()
|
|
r.label = label
|
|
r.doc_type = doc_type
|
|
r.url = url
|
|
r.completeness_pct = comp
|
|
r.correctness_pct = corr
|
|
r.error = ""
|
|
r.scenario = "fix"
|
|
checks = []
|
|
for i in range(n_checks):
|
|
c = MagicMock()
|
|
c.id = f"mc-{i}"
|
|
c.label = f"Check {i}"
|
|
c.passed = i >= n_fail
|
|
c.severity = "HIGH" if i < n_fail else "LOW"
|
|
c.skipped = False
|
|
c.hint = f"Hint {i}" if i < n_fail else ""
|
|
c.regulation = "DSGVO"
|
|
c.level = 2
|
|
c.matched_text = ""
|
|
checks.append(c)
|
|
r.checks = checks
|
|
return r
|
|
|
|
|
|
def _full_state():
|
|
return {
|
|
"check_id": "abc12345",
|
|
"site_name": "Example AG",
|
|
"domain": "example.de",
|
|
"doc_count": 5,
|
|
"results": [
|
|
_mock_check_result("Impressum", "impressum",
|
|
"https://example.de/impressum", 95, 92, 10, 1),
|
|
_mock_check_result("Datenschutzerklärung", "dse",
|
|
"https://example.de/dse", 80, 63, 20, 5),
|
|
],
|
|
"total_findings": 6,
|
|
"cmp_vendors": [
|
|
{"name": "Google", "source": "table_crawled",
|
|
"cookies": [
|
|
{"name": "_ga", "category": "Statistik",
|
|
"duration": "14 Monate"},
|
|
{"name": "_gid", "category": "Statistik",
|
|
"duration": "24h"},
|
|
]},
|
|
{"name": "Meta", "source": "html_table",
|
|
"cookies": [
|
|
{"name": "_fbp", "category": "Marketing",
|
|
"duration": "3 Monate"},
|
|
]},
|
|
],
|
|
"banner_result": {
|
|
"banner_detected": True,
|
|
"banner_provider": "Cookiebot",
|
|
"cookies_detailed": [
|
|
{"name": "_ga", "domain": "example.de"},
|
|
{"name": "_fbp", "domain": "facebook.net"},
|
|
{"name": "undocumented_pixel", "domain": "tracker.com"},
|
|
],
|
|
"banner_checks": {"violations": [{"id": "v1"}]},
|
|
},
|
|
"cookie_audit": {
|
|
"declared_count": 3,
|
|
"browser_count": 3,
|
|
"undeclared_in_browser": [{"name": "undocumented_pixel"}],
|
|
"compliant": [{"name": "_ga"}, {"name": "_fbp"}],
|
|
},
|
|
"scorecard": {"totals": {"pct": 72}},
|
|
"retention_findings": [
|
|
{"matches": False, "cookie_name": "_ga", "vendor_name": "Google",
|
|
"severity": "HIGH", "severity_reason": "factually_wrong",
|
|
"mismatch_type": "dsi_under_actual",
|
|
"dsi_days": 180, "table_days": 420, "actual_days": 420,
|
|
"diff_days": 240},
|
|
{"matches": True, "cookie_name": "_fbp", "severity": None,
|
|
"severity_reason": None},
|
|
],
|
|
"retention_theme_summary": {
|
|
"theme_id": "TH-RETENTION", "total": 2, "passed": 1,
|
|
"failed": 1, "incomplete": 0, "pct": 50,
|
|
"by_severity": {"HIGH": 1}, "by_mismatch_type": {},
|
|
"top_fails": [],
|
|
},
|
|
"reachability_finding": {
|
|
"check_id": "COOKIE-CONSENT-UX-001",
|
|
"passed": False, "severity": "HIGH",
|
|
"severity_reason": "missing",
|
|
"notes": ["no consent-manager link in footer"],
|
|
"reopen_anchor": None,
|
|
"anchors_total": 0,
|
|
},
|
|
"cookie_evidence_slices": [{"idx": 0}, {"idx": 1}],
|
|
"cookie_evidence_meta": {"url": "https://example.de/cookie"},
|
|
"audit_quality_findings": [
|
|
{"severity": "MEDIUM", "title": "Cookie-URL nicht erreichbar",
|
|
"message": "Auto-Discovery hat keine Alternative gefunden."},
|
|
],
|
|
}
|
|
|
|
|
|
class TestStyleHelpers:
|
|
def test_section_wraps_with_title(self):
|
|
out = section("My Section", "<p>body</p>")
|
|
assert "My Section" in out
|
|
assert "<p>body</p>" in out
|
|
|
|
def test_chip_renders_text(self):
|
|
out = chip("FAIL", "fail")
|
|
assert "FAIL" in out
|
|
|
|
def test_table_basic(self):
|
|
out = table(["A", "B"], [["1", "2"], ["3", "4"]])
|
|
assert "<thead>" in out
|
|
assert ">1<" in out and ">4<" in out
|
|
|
|
def test_kpi_row_4(self):
|
|
out = kpi_row([
|
|
{"label": "Score", "value": "92%", "sev": "pass"},
|
|
{"label": "Findings", "value": "3"},
|
|
{"label": "Docs", "value": "5/7"},
|
|
{"label": "Vendors", "value": "12"},
|
|
])
|
|
assert "Score" in out and "92%" in out
|
|
|
|
def test_card_with_sev(self):
|
|
out = card("inner", sev="warn")
|
|
assert "inner" in out
|
|
|
|
def test_page_wraps(self):
|
|
head = page_open("Foo")
|
|
tail = page_close("abc123", "deadbee")
|
|
assert "Foo" not in head # site_name not in open shell
|
|
assert "abc123" in tail and "deadbee" in tail
|
|
|
|
|
|
class TestCookieInventory:
|
|
def test_merge_declared_and_browser(self):
|
|
st = _full_state()
|
|
rows, summary = build_cookie_inventory(st)
|
|
assert summary["total"] >= 3
|
|
names = [r["name"].lower() for r in rows]
|
|
assert "_ga" in names
|
|
assert "undocumented_pixel" in names
|
|
|
|
def test_status_undoc_for_browser_only(self):
|
|
st = _full_state()
|
|
rows, _ = build_cookie_inventory(st)
|
|
undoc = next(r for r in rows
|
|
if r["name"].lower() == "undocumented_pixel")
|
|
assert undoc["status_code"] == "UNDOC"
|
|
assert undoc["status_sev"] == "fail"
|
|
|
|
def test_status_ok_for_compliant(self):
|
|
st = _full_state()
|
|
rows, _ = build_cookie_inventory(st)
|
|
ga = next(r for r in rows if r["name"].lower() == "_ga")
|
|
assert ga["status_code"] == "OK"
|
|
|
|
def test_empty_state(self):
|
|
rows, summary = build_cookie_inventory({})
|
|
assert rows == []
|
|
assert summary["total"] == 0
|
|
|
|
|
|
class TestComposeV2:
|
|
def test_full_render(self):
|
|
st = _full_state()
|
|
html = compose_v2(st)
|
|
# Header
|
|
assert "Example AG" in html
|
|
assert "example.de" in html
|
|
# KPIs
|
|
assert "72%" in html
|
|
# Critical
|
|
assert "1. Kritische Befunde" in html
|
|
# Per Doc
|
|
assert "Impressum" in html
|
|
assert "Datenschutzerklärung" in html
|
|
# Per Theme
|
|
assert "Cookie-Inventar" in html
|
|
assert "UNDOC" in html
|
|
# Reachability
|
|
assert "Mobile Reachability" in html
|
|
# Retention
|
|
assert "TH-RETENTION" in html
|
|
# Caveats
|
|
assert "Cookie-URL nicht erreichbar" in html
|
|
# Attachments
|
|
assert "evidence-abc12345" in html
|
|
|
|
def test_no_critical_when_clean(self):
|
|
st = _full_state()
|
|
st["results"] = []
|
|
st["reachability_finding"] = {"passed": True, "severity": None,
|
|
"notes": []}
|
|
st["retention_findings"] = []
|
|
html = compose_v2(st)
|
|
assert "Keine HIGH/CRITICAL-Befunde" in html
|