From b0115cb10beb0b29d85f8f25a58ec2d651df881d Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 11 Jun 2026 10:33:33 +0200 Subject: [PATCH] feat(cookie): 2. Sicht Banner-Kategorie + Fehl-Einsortierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CookieResultView bekommt einen Umschalter [Rechtliche Rolle] ↔ [Banner-Kategorie] (Notwendig/Funktional/Statistik/Marketing). In beiden Sichten zeigt jede Cookie-Zeile '→ sollte: Marketing', wenn die tatsächliche Kategorie laut Library von der deklarierten abweicht (rot bei Tracker als notwendig, § 25 TDDDG). Neue KPI 'Falsch einsortiert'. Backend liefert dazu cookie_categories (name→actual_category) aus big_lib im cookie-check-Output; Seite lädt cookie-check einmal und reicht es an beide Komponenten. Co-Authored-By: Claude Opus 4.7 --- .../agent/_components/CookieLibraryPanel.tsx | 11 +- .../agent/_components/CookieResultView.tsx | 185 ++++++++++++++---- .../__tests__/CookieResultView.test.tsx | 16 ++ .../sdk/agent/snapshots/[snapshotId]/page.tsx | 15 +- .../services/cookie_library_check.py | 6 + .../tests/test_cookie_library_check.py | 13 ++ 6 files changed, 201 insertions(+), 45 deletions(-) diff --git a/admin-compliance/app/sdk/agent/_components/CookieLibraryPanel.tsx b/admin-compliance/app/sdk/agent/_components/CookieLibraryPanel.tsx index bc824546..6fb7b6cf 100644 --- a/admin-compliance/app/sdk/agent/_components/CookieLibraryPanel.tsx +++ b/admin-compliance/app/sdk/agent/_components/CookieLibraryPanel.tsx @@ -139,11 +139,14 @@ export function CookieFindingList({ data }: { data: CheckData }) { ) } -export function CookieLibraryPanel({ snapshotId }: { snapshotId: string }) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) +export function CookieLibraryPanel( + { snapshotId, data: provided }: { snapshotId: string; data?: CheckData }, +) { + const [data, setData] = useState(provided ?? null) + const [loading, setLoading] = useState(!provided) useEffect(() => { + if (provided) { setData(provided); setLoading(false); return } let cancelled = false fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/cookie-check`) .then(r => r.json()) @@ -151,7 +154,7 @@ export function CookieLibraryPanel({ snapshotId }: { snapshotId: string }) { .catch(() => { if (!cancelled) setData({ findings: [] }) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } - }, [snapshotId]) + }, [snapshotId, provided]) if (loading) return
Library-Abgleich läuft…
return diff --git a/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx b/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx index 9c673d9c..8eb6512e 100644 --- a/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx +++ b/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx @@ -4,9 +4,12 @@ * CookieResultView — strukturierte Cookie-/Vendor-Auswertung aus einem * gespeicherten Snapshot (cmp_vendors), OHNE Re-Crawl. * - * KPIs + Empfänger-Gruppen (Eigene / Auftragsverarbeiter / Joint Controller — - * wie im Audit-Mail-VVT) + aufklappbare Vendor→Cookie-Tabelle. Verarbeitet - * Mengen (780 Cookies bei BMW): Vendors gruppiert, Cookies on-demand. + * Zwei Sichten (Umschalter): + * - Rechtliche Rolle: Eigene / Auftragsverarbeiter / Joint Controller (VVT) + * - Banner-Kategorie: Notwendig / Funktional / Statistik / Marketing — die im + * Consent-Banner implementierte Einteilung. Pro Cookie wird die tatsächliche + * Kategorie laut Library gegengeprüft → '→ sollte: Marketing' bei + * Fehl-Einsortierung (Tracker als notwendig = § 25 TDDDG-relevant). */ import React, { useMemo, useState } from 'react' @@ -37,6 +40,9 @@ interface Snapshot { cmp_vendors?: SnapshotVendor[] } +// name_lower → tatsächliche Kategorie laut Library (aus /cookie-check). +export type LibCategories = Record + const ROLE_LABEL: Record = { unknown: 'Unbekannt', ad_pixel: 'Werbe-Pixel', auth_token: 'Auth-Token', preference: 'Präferenz', visitor_id: 'Besucher-ID', consent_state: 'Consent', @@ -57,6 +63,45 @@ const GROUPS = [ { key: 'other', label: 'Sonstige Empfänger', test: () => true }, ] +// Banner-Kategorie-Sicht: kanonische Buckets + Labels. +const CAT_CANON: Record = { + necessary: 'necessary', essential: 'necessary', notwendig: 'necessary', + essenziell: 'necessary', security: 'necessary', 'strictly necessary': 'necessary', + functional: 'functional', funktional: 'functional', preferences: 'functional', + preference: 'functional', präferenzen: 'functional', + statistics: 'statistics', statistik: 'statistics', analytics: 'statistics', + performance: 'statistics', + marketing: 'marketing', targeting: 'marketing', advertising: 'marketing', + werbung: 'marketing', social_media: 'marketing', social: 'marketing', ad: 'marketing', +} +const CANON_LABEL: Record = { + necessary: 'Notwendig', functional: 'Funktional', + statistics: 'Statistik', marketing: 'Marketing', unknown: '—', +} +const CATEGORY_GROUPS = [ + { key: 'necessary', label: 'Notwendig (essenziell)' }, + { key: 'functional', label: 'Funktional' }, + { key: 'statistics', label: 'Statistik' }, + { key: 'marketing', label: 'Marketing' }, + { key: 'unknown', label: 'Ohne Kategorie' }, +] + +function canonCat(c?: string): string { + return CAT_CANON[(c || '').toLowerCase().trim()] || 'unknown' +} + +// Tatsächliche Kategorie laut Library vs. deklarierte Banner-Kategorie. +function mismatch(name: string, declaredCanon: string, lib?: LibCategories) { + const raw = lib?.[name.toLowerCase()] + if (!raw) return null + const actual = canonCat(raw) + if (actual === 'unknown' || actual === declaredCanon) return null + // severe: als notwendig deklariert, laut Library einwilligungspflichtig. + const severe = declaredCanon === 'necessary' + && (actual === 'marketing' || actual === 'statistics') + return { actual, severe } +} + function scoreColor(s?: number): string { if (s == null) return 'text-gray-400' return s >= 80 ? 'text-green-700' : s >= 50 ? 'text-amber-700' : 'text-red-700' @@ -71,10 +116,11 @@ function Tile({ label, value, tone }: { label: string; value: React.ReactNode; t ) } -function VendorRow({ v }: { v: SnapshotVendor }) { +function VendorRow({ v, lib }: { v: SnapshotVendor; lib?: LibCategories }) { const [open, setOpen] = useState(false) const cookies = v.cookies || [] const cat = (v.category || '').toLowerCase() + const declaredCanon = canonCat(v.category) const drittland = !!v.country && !EEA.has((v.country || '').toUpperCase()) return (
@@ -111,22 +157,35 @@ function VendorRow({ v }: { v: SnapshotVendor }) { - {cookies.map((c, i) => ( - - {c.name} - - {c.functional_role && c.functional_role !== 'unknown' - ? (ROLE_LABEL[c.functional_role] || c.functional_role) - : } - - - {c.purpose - ? c.purpose - : kein Zweck} - - {c.expiry || '—'} - - ))} + {cookies.map((c, i) => { + const mm = mismatch(c.name, declaredCanon, lib) + return ( + + + {c.name} + {mm && ( + + → sollte: {CANON_LABEL[mm.actual]} + + )} + + + {c.functional_role && c.functional_role !== 'unknown' + ? (ROLE_LABEL[c.functional_role] || c.functional_role) + : } + + + {c.purpose + ? c.purpose + : kein Zweck} + + {c.expiry || '—'} + + ) + })}
@@ -135,48 +194,96 @@ function VendorRow({ v }: { v: SnapshotVendor }) { ) } -export function CookieResultView({ snapshot }: { snapshot: Snapshot }) { +export function CookieResultView( + { snapshot, cookieCategories }: + { snapshot: Snapshot; cookieCategories?: LibCategories }, +) { const vendors = snapshot.cmp_vendors || [] + const [viewMode, setViewMode] = useState<'role' | 'category'>('role') + const stats = useMemo(() => { const cookies = vendors.reduce((n, v) => n + (v.cookies?.length || 0), 0) const marketing = vendors.filter(v => (v.category || '').toLowerCase() === 'marketing').length const drittland = vendors.filter(v => v.country && !EEA.has(v.country.toUpperCase())).length - return { cookies, marketing, drittland } - }, [vendors]) + let misplaced = 0 + for (const v of vendors) { + const dc = canonCat(v.category) + for (const c of v.cookies || []) { + if (mismatch(c.name, dc, cookieCategories)?.severe) misplaced++ + } + } + return { cookies, marketing, drittland, misplaced } + }, [vendors, cookieCategories]) - const grouped = useMemo(() => GROUPS.map(g => ({ - ...g, - vendors: vendors - .filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key) - .sort((a, b) => (a.compliance_score ?? 100) - (b.compliance_score ?? 100)), - })).filter(g => g.vendors.length > 0), [vendors]) + const grouped = useMemo(() => { + const sortByScore = (a: SnapshotVendor, b: SnapshotVendor) => + (a.compliance_score ?? 100) - (b.compliance_score ?? 100) + if (viewMode === 'category') { + return CATEGORY_GROUPS + .map(g => ({ ...g, vendors: vendors.filter(v => canonCat(v.category) === g.key).sort(sortByScore) })) + .filter(g => g.vendors.length > 0) + } + return GROUPS + .map(g => ({ + ...g, + vendors: vendors + .filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key) + .sort(sortByScore), + })) + .filter(g => g.vendors.length > 0) + }, [vendors, viewMode]) + + const toggleBtn = (mode: 'role' | 'category', label: string) => ( + + ) return (
-
-

- Cookie-Auswertung — {snapshot.site_domain || 'Snapshot'} -

-

- aus gespeichertem Snapshot (kein Re-Crawl) ·{' '} - {snapshot.created_at ? snapshot.created_at.slice(0, 19).replace('T', ' ') : ''} -

+
+
+

+ Cookie-Auswertung — {snapshot.site_domain || 'Snapshot'} +

+

+ aus gespeichertem Snapshot (kein Re-Crawl) ·{' '} + {snapshot.created_at ? snapshot.created_at.slice(0, 19).replace('T', ' ') : ''} +

+
+
+ Gruppierung: + {toggleBtn('role', 'Rechtliche Rolle')} + {toggleBtn('category', 'Banner-Kategorie')} +
-
+
0 ? 'text-red-700' : 'text-gray-800'} /> 0 ? 'text-amber-700' : 'text-gray-800'} /> + 0 ? 'text-red-700' : 'text-gray-800'} />
+ {viewMode === 'category' && ( +

+ Banner-Kategorie wie im Consent-Tool deklariert. Badge{' '} + → sollte: …{' '} + zeigt die tatsächliche Kategorie laut Library (Fehl-Einsortierung). +

+ )} + {grouped.map(g => (
{g.label} ({g.vendors.length})
- {g.vendors.map((v, i) => )} + {g.vendors.map((v, i) => )}
))} diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx index 27bdba19..0b9afd33 100644 --- a/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx +++ b/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx @@ -50,4 +50,20 @@ describe('CookieResultView', () => { expect(screen.getByText('LSKey-c$Policy')).toBeInTheDocument() expect(screen.getByText(/kein Zweck/)).toBeInTheDocument() // leerer purpose }) + + it('schaltet auf die Banner-Kategorie-Sicht um', () => { + render() + fireEvent.click(screen.getByText('Banner-Kategorie')) + expect(screen.getByText(/Notwendig \(essenziell\)/)).toBeInTheDocument() + expect(screen.getByText('Salesforce')).toBeInTheDocument() + expect(screen.getByText('Meta / Facebook')).toBeInTheDocument() + }) + + it('markiert falsch einsortierte Cookies (Tracker als notwendig)', () => { + // 'sid' ist als necessary deklariert, Library sagt marketing → § 25-relevant. + render() + expect(screen.getByText('Falsch einsortiert (lt. Library)')).toBeInTheDocument() + fireEvent.click(screen.getByText('Salesforce')) + expect(screen.getByText(/sollte: Marketing/)).toBeInTheDocument() + }) }) diff --git a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx index 999689ef..6b733b7a 100644 --- a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx +++ b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx @@ -17,6 +17,7 @@ export default function SnapshotDetail( ) { const { snapshotId } = useUnwrap(params) const [snap, setSnap] = useState(null) + const [check, setCheck] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -34,6 +35,16 @@ export default function SnapshotDetail( return () => { cancelled = true } }, [snapshotId]) + // Library-Abgleich einmal laden (Findings + cookie_categories für beide Views). + useEffect(() => { + let cancelled = false + fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/cookie-check`) + .then(r => r.json()) + .then(d => { if (!cancelled) setCheck(d) }) + .catch(() => { if (!cancelled) setCheck(null) }) + return () => { cancelled = true } + }, [snapshotId]) + const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0 return ( @@ -47,8 +58,8 @@ export default function SnapshotDetail(
Snapshot nicht gefunden.
) : hasCookies ? ( <> - - + + ) : (
diff --git a/backend-compliance/compliance/services/cookie_library_check.py b/backend-compliance/compliance/services/cookie_library_check.py index 2be9b715..8ad1f9b3 100644 --- a/backend-compliance/compliance/services/cookie_library_check.py +++ b/backend-compliance/compliance/services/cookie_library_check.py @@ -125,6 +125,9 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict: in_library = 0 seen_third: set[str] = set() seen_alt: set[str] = set() + # name_lower → tatsächliche Kategorie laut Library (für die Banner-Sicht: + # zeigt, wo ein Cookie eigentlich hingehört, falls falsch einsortiert). + cookie_cats: dict[str, str] = {} for v in vendors or []: vcat = (v.get("category") or "").lower() @@ -149,6 +152,8 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict: }) rich = lookup_cookie(name) or {} big = big_lib.get(name.lower(), {}) + if big.get("actual_category"): + cookie_cats[name.lower()] = big["actual_category"] if not rich and not big: continue in_library += 1 @@ -268,4 +273,5 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict: "findings": len(findings), }, "findings": findings, + "cookie_categories": cookie_cats, } diff --git a/backend-compliance/compliance/tests/test_cookie_library_check.py b/backend-compliance/compliance/tests/test_cookie_library_check.py index 1b094973..d3952d1f 100644 --- a/backend-compliance/compliance/tests/test_cookie_library_check.py +++ b/backend-compliance/compliance/tests/test_cookie_library_check.py @@ -139,6 +139,19 @@ def test_no_missing_retention_when_vendor_has_cookies(): assert not [f for f in out["findings"] if f["type"] == "missing_retention"] +def test_cookie_categories_exposes_actual_library_category(): + # Für die Banner-Sicht: name_lower → tatsächliche Kategorie laut Library. + big = {"bmw_track_de": { + "actual_category": "marketing", "typical_max_age_seconds": 86400, + "purpose_de": "Tracking", "vendor_name": "BMW", + }} + out = analyze_cookies([{ + "name": "BMW", "category": "necessary", + "cookies": [{"name": "bmw_track_de", "purpose": "x", "expiry": "1 Tag"}], + }], big) + assert out["cookie_categories"]["bmw_track_de"] == "marketing" + + def test_big_library_covers_cookie_not_in_rich_db(): # Cookie nicht in der 35er rich-DB, aber in der grossen 2287er (big_lib). big = {"bmw_track_de": {