feat: Cookie-Banner ↔ Backend Integration (DSR, Retention, Consent Proof)

Phase 1: Vendor sync from service registry (82+ services → banner vendors)
Phase 2: Category-based retention (marketing=90d, statistics=790d, not hardcoded 365d)
Phase 3: DSR ↔ Banner email linking (link-email, by-email, Art.17 erasure, Art.15/20 export)
Phase 4: Consent sync (Banner → Einwilligungen bridge)
Phase 6: Consent proof (SHA256 config hash + config_version in audit log, Art. 7(1) DSGVO)

New files:
- banner_dsr_service.py — email linking + DSR integration
- vendor_banner_sync.py — service registry → vendor configs
- migration 106 — linked_email, banner_config_hash, consent_version columns

Tests: 20+ new backend tests + 2 Playwright E2E test suites (API + UI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-02 19:41:22 +02:00
parent c3f8e19e92
commit 44acd68c96
12 changed files with 1522 additions and 5 deletions
@@ -312,3 +312,225 @@ class TestStats:
assert data["category_acceptance"]["necessary"]["count"] == 3
assert data["category_acceptance"]["analytics"]["count"] == 2
assert data["category_acceptance"]["marketing"]["count"] == 1
# =============================================================================
# Phase 3: Email Linking
# =============================================================================
class TestEmailLinking:
def test_link_email(self):
_record_consent()
r = client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": "user@example.com",
}, headers=HEADERS)
assert r.status_code == 200
assert r.json()["linked_email"] == "user@example.com"
def test_link_email_normalizes(self):
_record_consent()
r = client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": " User@Example.COM ",
}, headers=HEADERS)
assert r.status_code == 200
assert r.json()["linked_email"] == "user@example.com"
def test_link_email_invalid(self):
_record_consent()
r = client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": "not-an-email",
}, headers=HEADERS)
assert r.status_code == 400
def test_link_email_no_consent(self):
r = client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "nonexistent",
"email": "user@example.com",
}, headers=HEADERS)
assert r.status_code == 404
def test_get_consents_by_email(self):
_record_consent(fingerprint="dev-a")
_record_consent(fingerprint="dev-b")
# Link both to same email
for fp in ["dev-a", "dev-b"]:
client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": fp,
"email": "multi@example.com",
}, headers=HEADERS)
r = client.get("/api/compliance/banner/consent/by-email/multi@example.com", headers=HEADERS)
assert r.status_code == 200
assert len(r.json()) == 2
def test_get_consents_by_email_empty(self):
r = client.get("/api/compliance/banner/consent/by-email/nobody@nowhere.com", headers=HEADERS)
assert r.status_code == 200
assert r.json() == []
def test_delete_consents_by_email_art17(self):
_record_consent(fingerprint="del-a")
_record_consent(fingerprint="del-b")
for fp in ["del-a", "del-b"]:
client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": fp,
"email": "erasure@example.com",
}, headers=HEADERS)
r = client.delete("/api/compliance/banner/consent/by-email/erasure@example.com", headers=HEADERS)
assert r.status_code == 200
data = r.json()
assert data["deleted"] == 2
assert data["email"] == "erasure@example.com"
# Verify gone
r2 = client.get("/api/compliance/banner/consent/by-email/erasure@example.com", headers=HEADERS)
assert r2.json() == []
def test_dsr_export_by_email(self):
_record_consent(fingerprint="exp-1")
client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "exp-1",
"email": "export@example.com",
}, headers=HEADERS)
r = client.get("/api/compliance/banner/consent/dsr-export/export@example.com", headers=HEADERS)
assert r.status_code == 200
data = r.json()
assert data["email"] == "export@example.com"
assert len(data["banner_consents"]) == 1
assert len(data["audit_trail"]) >= 1
# =============================================================================
# Phase 4: Consent Sync (Banner → Einwilligungen)
# =============================================================================
class TestConsentSync:
def test_sync_consent(self):
_record_consent(categories=["necessary", "analytics"])
r = client.post("/api/compliance/banner/consent/sync", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": "sync@example.com",
}, headers=HEADERS)
assert r.status_code == 200
data = r.json()
assert data["synced"] >= 1
assert "necessary" in data["categories"]
assert data["email"] == "sync@example.com"
def test_sync_consent_links_email(self):
_record_consent()
# Sync should auto-link email
client.post("/api/compliance/banner/consent/sync", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": "autolink@example.com",
}, headers=HEADERS)
r = client.get("/api/compliance/banner/consent/by-email/autolink@example.com", headers=HEADERS)
assert len(r.json()) == 1
def test_sync_no_consent(self):
r = client.post("/api/compliance/banner/consent/sync", json={
"site_id": "example.com",
"device_fingerprint": "nonexistent",
"email": "test@example.com",
}, headers=HEADERS)
assert r.status_code == 404
# =============================================================================
# Phase 2: Retention per Category
# =============================================================================
class TestRetention:
def test_retention_uses_vendor_max(self):
"""Consent expiry should use max vendor retention, not hardcoded 365."""
_create_site()
# Add marketing vendor with 90 days retention
client.post("/api/compliance/banner/admin/sites/example.com/vendors", json={
"vendor_name": "FB Pixel",
"category_key": "marketing",
"retention_days": 90,
}, headers=HEADERS)
c = _record_consent(categories=["necessary", "marketing"])
# Consent should expire in 90 days (max of vendor retentions)
from datetime import datetime
created = datetime.fromisoformat(c["created_at"])
expires = datetime.fromisoformat(c["expires_at"])
diff_days = (expires - created).days
assert 89 <= diff_days <= 91, f"Expected ~90 days, got {diff_days}"
def test_retention_default_365(self):
"""Without vendor config, should use category default."""
c = _record_consent(categories=["necessary"])
from datetime import datetime
created = datetime.fromisoformat(c["created_at"])
expires = datetime.fromisoformat(c["expires_at"])
diff_days = (expires - created).days
assert 364 <= diff_days <= 366, f"Expected ~365 days, got {diff_days}"
# =============================================================================
# Phase 6: Consent Proof (Art. 7(1) DSGVO)
# =============================================================================
class TestConsentProof:
def test_consent_has_linked_email_field(self):
"""New consent should include linked_email in response."""
c = _record_consent()
assert "linked_email" in c
assert c["linked_email"] is None # Not linked yet
def test_site_config_has_version(self):
"""Site config should have config_version field."""
s = _create_site()
assert "config_version" in s
assert s["config_version"] == 1
def test_audit_trail_after_consent(self):
"""Audit trail should exist after recording consent."""
_record_consent()
r = client.get(
"/api/compliance/banner/consent/export?site_id=example.com&device_fingerprint=fp-123",
headers=HEADERS,
)
data = r.json()
assert len(data["audit_trail"]) >= 1
audit = data["audit_trail"][0]
assert audit["action"] in ("consent_given", "consent_updated")
# =============================================================================
# IP Hashing
# =============================================================================
class TestIPHashing:
def test_ip_is_hashed(self):
"""IP address should never be stored plain — only SHA256[:16]."""
c = _record_consent()
assert c["ip_hash"] is not None
assert c["ip_hash"] != "1.2.3.4"
assert len(c["ip_hash"]) == 16
def test_no_ip_returns_none(self):
r = client.post("/api/compliance/banner/consent", json={
"site_id": "example.com",
"device_fingerprint": "fp-no-ip",
"categories": ["necessary"],
}, headers=HEADERS)
assert r.status_code == 200
assert r.json()["ip_hash"] is None