feat(agent): migrate compliance-check results to banner + documents (M1-M5)
After a compliance-check run finishes, the user can now apply the
extracted vendor inventory directly to their own:
- CookieBanner config (admin /sdk/einwilligungen)
- Cookie-Policy / VVT-Register / Privacy-Policy templates
(admin /sdk/document-generator)
Backend:
- migration_to_banner.py: vendor list -> CookieBannerConfig with
ESSENTIAL/PERFORMANCE/PERSONALIZATION/EXTERNAL_MEDIA buckets +
review flags (broken opt-out URLs, missing expiry, no cookies listed)
- migration_to_document.py: vendor list -> pre-fills for 3 doc
templates, recipient-type aware (INTERNAL/GROUP/PROCESSOR/CONTROLLER)
- agent_migration_routes.py: GET /banner-preview, /document-preview,
/summary keyed on check_id
- compliance_audit_log: new check_payloads table persists cmp_vendors +
extracted_profile so the preview survives an app restart
- tests: 9 mapper units + 4 endpoint integration tests
Frontend:
- MigrationPanel.tsx: modal showing banner-config diff + document
pre-fills, plus links into the existing editors
- ComplianceCheckTab.tsx: replaces standalone audit link with the
panel; net -3 lines, stays at the 500-cap
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Integration test for the /compliance/agent/migration/* endpoints.
|
||||
|
||||
Simulates a finished compliance-check run by persisting cmp_vendors +
|
||||
extracted_profile via the sidecar audit log, then exercises the FastAPI
|
||||
TestClient against banner-preview / document-preview / summary.
|
||||
|
||||
This is the M5 BMW-scenario in miniature: realistic ePaaS-shaped vendor
|
||||
records (BMW INTERNAL + 2 third-party PROCESSOR) feed through to a
|
||||
ready-to-apply banner config and pre-filled documents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app_client(monkeypatch):
|
||||
# Isolate the sidecar SQLite so this test never races with /data prod DB
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("COMPLIANCE_AUDIT_DB", os.path.join(tmp, "audit.db"))
|
||||
# Build a minimal app — avoid importing the full main.py which pulls in
|
||||
# smtp_sender / weasyprint / pydantic-v1 modules not relevant here.
|
||||
from fastapi import FastAPI
|
||||
from compliance.api.agent_migration_routes import router
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_check_id():
|
||||
"""Persist a fake compliance check so the migration routes have data."""
|
||||
from compliance.services.compliance_audit_log import record_check_payload, record_check_run
|
||||
cid = "bmw-test-check-001"
|
||||
vendors = [
|
||||
{
|
||||
"name": "BMW AG", "category": "necessary",
|
||||
"recipient_type": "INTERNAL",
|
||||
"purpose": "Grundfunktionen + Login",
|
||||
"cookies": [{"name": "JSESSIONID", "expiry": "Session"}],
|
||||
},
|
||||
{
|
||||
"name": "Adobe Analytics", "category": "statistics",
|
||||
"recipient_type": "PROCESSOR", "country": "US",
|
||||
"purpose": "Reichweitenmessung",
|
||||
"opt_out_url": "https://adobe.com/opt-out", "opt_out_ok": True,
|
||||
"privacy_policy_url": "https://adobe.com/privacy",
|
||||
"cookies": [{"name": "s_cc", "expiry": "1 Tag",
|
||||
"is_third_party": True}],
|
||||
},
|
||||
{
|
||||
"name": "YouTube", "category": "marketing",
|
||||
"recipient_type": "PROCESSOR", "country": "US",
|
||||
"purpose": "Videos",
|
||||
"cookies": [{"name": "VISITOR_INFO1_LIVE", "expiry": "6 Monate",
|
||||
"is_third_party": True}],
|
||||
},
|
||||
]
|
||||
record_check_run(
|
||||
check_id=cid, tenant_id="t1", site_name="bmw.de",
|
||||
base_domain="bmw.de", doc_count=4,
|
||||
scorecard={"totals": {"pct": 75, "passed": 30, "failed": 10,
|
||||
"total": 40, "skipped": 0}},
|
||||
)
|
||||
record_check_payload(check_id=cid, vendors=vendors,
|
||||
profile={"companyName": "BMW AG",
|
||||
"headquartersStreet": "Petuelring 130",
|
||||
"headquartersZip": "80809",
|
||||
"headquartersCity": "Muenchen",
|
||||
"dpoEmail": "datenschutz@bmw.de"})
|
||||
return cid
|
||||
|
||||
|
||||
def test_banner_preview_returns_valid_config(app_client, seeded_check_id):
|
||||
r = app_client.get(f"/api/compliance/agent/migration/{seeded_check_id}/banner-preview")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["summary"]["vendors_total"] == 3
|
||||
cat_ids = {c["id"] for c in body["config"]["categories"]}
|
||||
assert "ESSENTIAL" in cat_ids
|
||||
assert "EXTERNAL_MEDIA" in cat_ids # YouTube
|
||||
# BMW AG (INTERNAL, cookies present) should not raise any flags
|
||||
assert not any(f.get("vendor") == "BMW AG" for f in body["flags"])
|
||||
|
||||
|
||||
def test_document_preview_includes_all_three_templates(app_client, seeded_check_id):
|
||||
r = app_client.get(f"/api/compliance/agent/migration/{seeded_check_id}/document-preview")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["vendor_count"] == 3
|
||||
assert set(body["templates"].keys()) == {
|
||||
"cookie_policy", "vvt_register", "privacy_policy",
|
||||
}
|
||||
assert "BMW AG" in body["templates"]["cookie_policy"]["initialContent"]
|
||||
assert "Petuelring" in body["templates"]["cookie_policy"]["initialContent"]
|
||||
|
||||
|
||||
def test_summary_returns_overview(app_client, seeded_check_id):
|
||||
r = app_client.get(f"/api/compliance/agent/migration/{seeded_check_id}/summary")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["company_name"] == "BMW AG"
|
||||
assert body["vendor_count"] == 3
|
||||
assert body["site_name"] == "bmw.de"
|
||||
assert "cookie_policy" in body["available_templates"]
|
||||
|
||||
|
||||
def test_unknown_check_id_returns_404(app_client):
|
||||
r = app_client.get("/api/compliance/agent/migration/nope-not-there/banner-preview")
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Unit tests for vendor → CookieBannerConfig and vendor → Document pre-fill
|
||||
mappers (M1 + M2 of the customer-banner migration feature).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from compliance.services.migration_to_banner import (
|
||||
build_banner_config, map_category,
|
||||
)
|
||||
from compliance.services.migration_to_document import build_document_prefills
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_vendors() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"name": "BMW AG",
|
||||
"category": "necessary",
|
||||
"recipient_type": "INTERNAL",
|
||||
"purpose": "Sicherstellung der Grundfunktionen",
|
||||
"cookies": [{"name": "JSESSIONID", "expiry": "Session"}],
|
||||
},
|
||||
{
|
||||
"name": "Google Analytics",
|
||||
"category": "statistics",
|
||||
"recipient_type": "PROCESSOR",
|
||||
"purpose": "Reichweitenmessung",
|
||||
"country": "US",
|
||||
"opt_out_url": "https://tools.google.com/dlpage/gaoptout",
|
||||
"opt_out_ok": True,
|
||||
"privacy_policy_url": "https://policies.google.com/privacy",
|
||||
"cookies": [
|
||||
{"name": "_ga", "expiry": "2 Jahre", "is_third_party": True},
|
||||
{"name": "_gid", "expiry": "1 Tag", "is_third_party": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "YouTube",
|
||||
"category": "marketing",
|
||||
"recipient_type": "PROCESSOR",
|
||||
"purpose": "Eingebettete Videos",
|
||||
"cookies": [],
|
||||
},
|
||||
{
|
||||
"name": "Broken Pixel",
|
||||
"category": "marketing",
|
||||
"recipient_type": "PROCESSOR",
|
||||
"purpose": "Werbung",
|
||||
"opt_out_url": "https://example.com/optout",
|
||||
"opt_out_ok": False,
|
||||
"opt_out_status": 404,
|
||||
"cookies": [{"name": "_pix", "expiry": ""}],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_map_category_youtube_routes_to_external_media():
|
||||
assert map_category("marketing", "YouTube Player") == "EXTERNAL_MEDIA"
|
||||
|
||||
|
||||
def test_map_category_unknown_falls_back_to_personalization():
|
||||
assert map_category("weird-cat", "Some Vendor") == "PERSONALIZATION"
|
||||
|
||||
|
||||
def test_map_category_necessary_is_essential():
|
||||
assert map_category("necessary", "JSESSIONID") == "ESSENTIAL"
|
||||
|
||||
|
||||
def test_build_banner_config_buckets_categories(sample_vendors):
|
||||
out = build_banner_config(sample_vendors, site_name="bmw.de")
|
||||
cats = {c["id"]: c for c in out["config"]["categories"]}
|
||||
assert "ESSENTIAL" in cats
|
||||
assert "PERFORMANCE" in cats
|
||||
assert any(c["name"] == "_ga" for c in cats["PERFORMANCE"]["cookies"])
|
||||
# YouTube vendor had no cookies → it should not pollute EXTERNAL_MEDIA
|
||||
# but should produce a WARNING flag for the missing list
|
||||
assert any(f["vendor"] == "YouTube" and f["issue"] == "no_cookies_listed"
|
||||
for f in out["flags"])
|
||||
|
||||
|
||||
def test_build_banner_config_flags_broken_opt_out(sample_vendors):
|
||||
out = build_banner_config(sample_vendors, site_name="bmw.de")
|
||||
errors = [f for f in out["flags"] if f["level"] == "ERROR"]
|
||||
assert any(f["issue"] == "broken_opt_out" and f["vendor"] == "Broken Pixel"
|
||||
for f in errors)
|
||||
|
||||
|
||||
def test_build_banner_config_summary_counts(sample_vendors):
|
||||
out = build_banner_config(sample_vendors, site_name="bmw.de")
|
||||
s = out["summary"]
|
||||
assert s["vendors_total"] == 4
|
||||
assert s["vendors_with_no_cookies"] == 1
|
||||
assert s["cookies_total"] == 4 # JSESSIONID + _ga + _gid + _pix
|
||||
|
||||
|
||||
def test_build_document_prefills_emits_all_three_templates(sample_vendors):
|
||||
out = build_document_prefills(
|
||||
sample_vendors,
|
||||
extracted_profile={
|
||||
"company_profile": {
|
||||
"companyName": "BMW AG",
|
||||
"headquartersStreet": "Petuelring 130",
|
||||
"headquartersZip": "80809",
|
||||
"headquartersCity": "Muenchen",
|
||||
"dpoEmail": "datenschutz@bmw.de",
|
||||
},
|
||||
},
|
||||
site_name="bmw.de",
|
||||
)
|
||||
assert set(out.keys()) == {"cookie_policy", "vvt_register", "privacy_policy"}
|
||||
cp = out["cookie_policy"]
|
||||
assert cp["templateType"] == "cookie_policy"
|
||||
assert "BMW AG" in cp["initialContent"]
|
||||
assert "Google Analytics" in cp["initialContent"]
|
||||
assert "Petuelring 130" in cp["initialContent"]
|
||||
|
||||
|
||||
def test_vvt_register_marks_third_country_for_us_processor(sample_vendors):
|
||||
out = build_document_prefills(sample_vendors, site_name="bmw.de")
|
||||
acts = out["vvt_register"]["activities"]
|
||||
ga = next(a for a in acts if a["name"] == "Google Analytics")
|
||||
rcat = ga["recipientCategories"][0]
|
||||
assert rcat["type"] == "PROCESSOR"
|
||||
assert rcat["country"] == "US"
|
||||
assert rcat["isThirdCountry"] is True
|
||||
|
||||
|
||||
def test_privacy_policy_section_groups_by_recipient_type(sample_vendors):
|
||||
out = build_document_prefills(sample_vendors, site_name="bmw.de")
|
||||
body = out["privacy_policy"]["initialContent"]
|
||||
assert "Eigene Verarbeitung" in body
|
||||
assert "Auftragsverarbeiter" in body
|
||||
# BMW AG (INTERNAL) must appear under Eigene, not under Auftragsverarbeiter
|
||||
internal_block = body.split("### Auftragsverarbeiter")[0]
|
||||
assert "BMW AG" in internal_block
|
||||
Reference in New Issue
Block a user