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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user