From 9587726936b107ee26958cacc66b12ca358eecfb Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 12 Jun 2026 23:15:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Tab=20"Browser-Verhalten"=20?= =?UTF-8?q?=E2=80=94=20Per-Browser-Matrix=20+=20Screenshots=20(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrowserBehaviorView: laedt gespeicherte Matrix (GET), sonst "Browser-Test starten" (POST run, Live-Lauf). Per-Browser-Tabelle (Cookies vor Consent / nach Ablehnen / Ablehnen respektiert / Oberflaeche / Score), Engine-Detail mit Banner-Screenshot + Oberflaechen-Befunden, Mobil-Badge, "nicht verfuegbar"-Zeilen fuer fehlende Browser (arm64-Dev). - Proxys browser-behavior (GET) + browser-behavior/run (POST, langer Timeout). - page.tsx: Tab "Browser-Verhalten" (sichtbar sobald scanbare URL im Snapshot). - consent-tester scan_matrix_summary: banner_findings je Engine im summary (Text/Severity/Norm) → Oberflaechen-Befunde im Tab. - tsc strict clean; Vitest BrowserBehaviorView (2). Co-Authored-By: Claude Opus 4.7 --- .../[snapshotId]/browser-behavior/route.ts | 33 +++ .../browser-behavior/run/route.ts | 44 ++++ .../agent/_components/BrowserBehaviorView.tsx | 206 ++++++++++++++++++ .../__tests__/BrowserBehaviorView.test.tsx | 57 +++++ .../sdk/agent/snapshots/[snapshotId]/page.tsx | 9 + .../services/scan_matrix_summary.py | 10 + 6 files changed, 359 insertions(+) create mode 100644 admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/browser-behavior/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/browser-behavior/run/route.ts create mode 100644 admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx create mode 100644 admin-compliance/app/sdk/agent/_components/__tests__/BrowserBehaviorView.test.tsx diff --git a/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/browser-behavior/route.ts b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/browser-behavior/route.ts new file mode 100644 index 00000000..25108bad --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/browser-behavior/route.ts @@ -0,0 +1,33 @@ +/** + * Browser-Verhaltens-Matrix — gespeichertes Ergebnis (kein Re-Crawl) + * GET /api/sdk/v1/agent/snapshots/{snapshotId}/browser-behavior + * → backend /api/compliance/agent/snapshots/{snapshotId}/browser-behavior + * + * `browser_matrix` ist null, solange der On-demand-Lauf nie ausgelöst wurde. + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = + process.env.BACKEND_API_URL || process.env.BACKEND_URL || + 'http://backend-compliance:8002' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ snapshotId: string }> }, +) { + const { snapshotId } = await params + try { + const response = await fetch( + `${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/browser-behavior`, + { signal: AbortSignal.timeout(30_000) }, + ) + const data = await response.json() + return NextResponse.json(data, { status: response.status }) + } catch { + return NextResponse.json( + { browser_matrix: null, error: 'Browser-Matrix laden fehlgeschlagen' }, + { status: 503 }, + ) + } +} diff --git a/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/browser-behavior/run/route.ts b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/browser-behavior/run/route.ts new file mode 100644 index 00000000..af8a6549 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/browser-behavior/run/route.ts @@ -0,0 +1,44 @@ +/** + * Browser-Verhaltens-Matrix — On-demand LIVE-Lauf (Re-Crawl je Engine) + * POST /api/sdk/v1/agent/snapshots/{snapshotId}/browser-behavior/run + * → backend /api/compliance/agent/snapshots/{snapshotId}/browser-behavior/run + * + * Teuer (mehrere Browser × 3 Phasen) → langer Timeout. Persistenz passiert + * im Backend; die Antwort ist die frische Matrix. + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = + process.env.BACKEND_API_URL || process.env.BACKEND_URL || + 'http://backend-compliance:8002' + +// Vercel-only Hinweis; self-hosted ignoriert es — schadet nicht. +export const maxDuration = 400 + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ snapshotId: string }> }, +) { + const { snapshotId } = await params + let body: unknown = {} + try { body = await request.json() } catch { body = {} } + try { + const response = await fetch( + `${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/browser-behavior/run`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + signal: AbortSignal.timeout(380_000), + }, + ) + const data = await response.json() + return NextResponse.json(data, { status: response.status }) + } catch (e) { + return NextResponse.json( + { error: `Browser-Test fehlgeschlagen: ${String(e)}` }, + { status: 504 }, + ) + } +} diff --git a/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx b/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx new file mode 100644 index 00000000..6ead9190 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx @@ -0,0 +1,206 @@ +'use client' + +/** + * BrowserBehaviorView — On-demand-Browser-Verhaltens-Matrix für einen Snapshot. + * Lädt das gespeicherte Ergebnis (GET, kein Re-Crawl); ohne Ergebnis ein + * „Browser-Test starten"-Button (POST run → Live-Lauf je Engine). Zeigt je + * Browser: Cookies vor Consent / nach Ablehnen / Ablehnen respektiert + Score, + * darunter Engine-Detail mit Banner-Screenshot + Oberflächen-Befunden. + * Aggregierte Maßnahmen + Cross-Finding folgen separat (Phase 4). + */ + +import React, { useEffect, useState } from 'react' + +type Finding = { text: string; severity: string; legal_ref?: string; service?: string } +type Surface = { has_impressum_link?: boolean; has_dse_link?: boolean; banner_text_issues?: number } +type Summary = { + cookies_before_consent?: number; cookies_after_reject?: number + reject_respected?: boolean; banner_detected?: boolean; banner_provider?: string + banner_screenshot_b64?: string; surface?: Surface; banner_findings?: Finding[] +} +type Row = { + profile_id: string; label: string; engine?: string; is_mobile?: boolean + score?: number; verbal?: string; summary?: Summary | null; error?: string +} +type Matrix = { browser_matrix?: Row[]; aggregate?: Record; url?: string; scanned_at?: string } + +const sevCls = (s: string) => { + const u = (s || '').toUpperCase() + if (u === 'CRITICAL' || u === 'HIGH') return 'bg-red-100 text-red-700' + if (u === 'MEDIUM') return 'bg-amber-100 text-amber-700' + return 'bg-gray-100 text-gray-600' +} +const scoreCls = (n?: number) => + n == null ? 'text-gray-400' : n >= 80 ? 'text-green-700' : n >= 60 ? 'text-amber-700' : 'text-red-700' + +export function BrowserBehaviorView({ snapshotId }: { snapshotId: string }) { + const [matrix, setMatrix] = useState(null) + const [loading, setLoading] = useState(true) + const [running, setRunning] = useState(false) + const [error, setError] = useState(null) + const [sel, setSel] = useState('') + + useEffect(() => { + let cancelled = false + fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/browser-behavior`) + .then(r => r.json()) + .then(d => { if (!cancelled) setMatrix(d?.browser_matrix || null) }) + .catch(() => { if (!cancelled) setMatrix(null) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [snapshotId]) + + const rows = matrix?.browser_matrix || [] + + useEffect(() => { + if (!sel && rows.length) { + const withData = rows.filter(r => r.summary) + const worst = [...(withData.length ? withData : rows)] + .sort((a, b) => (a.score ?? 999) - (b.score ?? 999))[0] + if (worst) setSel(worst.profile_id) + } + }, [rows, sel]) + + const run = async () => { + setRunning(true); setError(null) + try { + const r = await fetch( + `/api/sdk/v1/agent/snapshots/${snapshotId}/browser-behavior/run`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) + const d = await r.json() + if (!r.ok || d?.error) setError(d?.error || `Fehler ${r.status}`) + else { setMatrix(d); setSel('') } + } catch (e) { setError(String(e)) } finally { setRunning(false) } + } + + if (loading) return
Lade Browser-Verhalten…
+ + if (!matrix || !rows.length) { + return ( +
+

Browser-Verhalten testen

+

+ Prüft das Cookie-Banner live in mehreren Browser-Engines (Chromium, + Firefox/Gecko, Safari/WebKit) sowie – sofern verfügbar – in echtem + Chrome, Edge, Brave und mobil. Gemessen wird je Browser: werden + Cookies vor der Einwilligung gesetzt, und werden sie + nach „Ablehnen" wirklich entfernt? Dazu eine + Oberflächenanalyse (Impressum-/DSE-Links, Banner-Auffälligkeiten) mit + Screenshot je Engine. +

+

+ Der Test crawlt die Seite live und dauert je nach Browser-Anzahl + einige Minuten. +

+ {error &&
{error}
} + +
+ ) + } + + const selRow = rows.find(r => r.profile_id === sel) || rows[0] + const agg: Record = matrix.aggregate || {} + + return ( +
+
+
+ {matrix.scanned_at ? `Test vom ${String(matrix.scanned_at).slice(0, 16).replace('T', ' ')}` : ''} + {agg.profiles_run ? ` · ${String(agg.profiles_run)} Browser` : ''} + {' · '}Live-Messung, kann von der Snapshot-Zeit abweichen +
+ +
+ {error &&
{error}
} + +
+ + + + + + + + + + + + + {rows.map(r => { + const s = r.summary + const before = s?.cookies_before_consent ?? null + const after = s?.cookies_after_reject ?? null + const sld = r.profile_id === sel + return ( + setSel(r.profile_id)} + className={`border-t border-gray-100 cursor-pointer ${sld ? 'bg-blue-50' : 'hover:bg-gray-50'}`}> + + {r.error || !s ? ( + + ) : ( + <> + + + + + + )} + + + ) + })} + +
BrowserCookies vor ConsentCookies nach AblehnenAblehnen respektiertOberflächeScore
+ {r.label} + {r.is_mobile && Mobil} + + nicht verfügbar{r.error ? ` (${r.error.slice(0, 40)})` : ''} + 0 ? 'text-red-700 font-semibold' : 'text-green-700'}`}>{before} 0 ? 'text-amber-700' : 'text-green-700'}`}>{after} + {s.reject_respected ? : } + + {!s.surface?.has_impressum_link && Impressum fehlt } + {!s.surface?.has_dse_link && DSE fehlt } + {(s.surface?.banner_text_issues ?? 0) > 0 + ? {s.surface?.banner_text_issues} Hinweis(e) + : (s.surface?.has_impressum_link && s.surface?.has_dse_link ? ok : null)} + {r.score ?? '–'}
+
+ + {selRow && ( +
+
+

{selRow.label}

+ {selRow.verbal && · {selRow.verbal}} +
+ {selRow.summary?.banner_screenshot_b64 ? ( + {`Banner + ) : ( +
Kein Banner-Screenshot erfasst.
+ )} + {(selRow.summary?.banner_findings?.length ?? 0) > 0 ? ( +
    + {selRow.summary!.banner_findings!.map((f, i) => ( +
  • + {f.severity || 'INFO'} + + {f.text}{f.legal_ref && · {f.legal_ref}} + +
  • + ))} +
+ ) : selRow.summary ? ( +
Keine Oberflächen-Auffälligkeiten in dieser Engine.
+ ) : null} +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/BrowserBehaviorView.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/BrowserBehaviorView.test.tsx new file mode 100644 index 00000000..6224e30f --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/__tests__/BrowserBehaviorView.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' + +import { BrowserBehaviorView } from '../BrowserBehaviorView' + +function mockFetch(getBody: unknown) { + return vi.fn(async () => ({ + ok: true, status: 200, json: async () => getBody, + })) as unknown as typeof fetch +} + +describe('BrowserBehaviorView', () => { + afterEach(() => { vi.restoreAllMocks() }) + + it('zeigt den Start-Button, wenn noch keine Matrix existiert', async () => { + vi.stubGlobal('fetch', mockFetch({ browser_matrix: null })) + render() + expect(await screen.findByText('Browser-Test starten')).toBeInTheDocument() + expect(screen.getByText('Browser-Verhalten testen')).toBeInTheDocument() + }) + + it('rendert die Per-Browser-Tabelle + Befund der schlechtesten Engine', async () => { + const matrix = { + browser_matrix: { + browser_matrix: [ + { + profile_id: 'chromium-headed-de', label: 'Chromium', engine: 'blink', score: 92, + summary: { + cookies_before_consent: 0, cookies_after_reject: 0, reject_respected: true, + surface: { has_impressum_link: true, has_dse_link: true, banner_text_issues: 0 }, + banner_findings: [], + }, + }, + { + profile_id: 'firefox-headed-de', label: 'Firefox', engine: 'gecko', score: 40, + summary: { + cookies_before_consent: 3, cookies_after_reject: 2, reject_respected: false, + surface: { has_impressum_link: false, has_dse_link: true, banner_text_issues: 2 }, + banner_findings: [{ text: 'Ablehnen weniger prominent', severity: 'HIGH', legal_ref: '§ 25 TDDDG' }], + }, + }, + ], + aggregate: { profiles_run: 2, worst_score: 40, best_score: 92 }, + scanned_at: '2026-06-12T01:00:00Z', + }, + } + vi.stubGlobal('fetch', mockFetch(matrix)) + render() + expect(await screen.findByText('Chromium')).toBeInTheDocument() + // Firefox steht in der Tabellenzeile UND als Kopf des Engine-Details + // (schlechteste Engine ist vorausgewählt) → mehrfach erwartet. + expect(screen.getAllByText('Firefox').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Erneut testen')).toBeInTheDocument() + // Schlechteste Engine (Firefox, Score 40) ist vorausgewählt → Befund sichtbar. + expect(await screen.findByText(/Ablehnen weniger prominent/)).toBeInTheDocument() + }) +}) diff --git a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx index 890a49ca..692f0c3d 100644 --- a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx +++ b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx @@ -15,6 +15,7 @@ import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel' import { CookieDeclarationDiff } from '../../_components/CookieDeclarationDiff' import { CookieResultView } from '../../_components/CookieResultView' import { AgentModuleTab } from '../../_components/AgentModuleTab' +import { BrowserBehaviorView } from '../../_components/BrowserBehaviorView' export default function SnapshotDetail( { params }: { params: Promise<{ snapshotId: string }> }, @@ -53,12 +54,16 @@ export default function SnapshotDetail( const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0 const hasDoc = (dt: string) => docs.some( (e: any) => e.doc_type === dt && (e.text || e.content || '').length > 100) + // Browser-Verhalten braucht nur eine scanbare URL (on-demand-Live-Lauf). + const hasSite = docs.some((e: any) => (e.url || '').trim()) + || (!!snap?.site_domain && snap.site_domain !== 'unknown') const modules = useMemo(() => [ ...(hasCookies ? [{ key: 'cookie', label: 'Cookies & Tracking' }] : []), ...(hasDoc('impressum') ? [{ key: 'impressum', label: 'Impressum' }] : []), ...(hasDoc('dse') ? [{ key: 'dse', label: 'Datenschutzerklärung' }] : []), ...(hasDoc('agb') ? [{ key: 'agb', label: 'AGB' }] : []), + ...(hasSite ? [{ key: 'browser', label: 'Browser-Verhalten' }] : []), // eslint-disable-next-line react-hooks/exhaustive-deps ], [snap]) @@ -133,6 +138,10 @@ export default function SnapshotDetail( {tab === 'agb' && ( )} + + {tab === 'browser' && ( + + )} )} diff --git a/consent-tester/services/scan_matrix_summary.py b/consent-tester/services/scan_matrix_summary.py index c8658646..ed5d25f1 100644 --- a/consent-tester/services/scan_matrix_summary.py +++ b/consent-tester/services/scan_matrix_summary.py @@ -63,6 +63,16 @@ def matrix_scan_dict(result: Any) -> dict: getattr(result, "banner_has_dse_link", False)), "banner_text_issues": len(banner_text_violations), }, + # Oberflächen-Befunde je Engine (die 20 Banner-Checks: Button-Prominenz, + # Toggle-Vorauswahl, Einleitungstext/Links …) — Text + Severity + + # Norm-Bezug. Aggregierte Maßnahmen folgen im Cross-Finding. + "banner_findings": [ + {"text": d.get("text", ""), + "severity": d.get("severity", "MEDIUM"), + "legal_ref": d.get("legal_ref", ""), + "service": d.get("service", "")} + for d in (_vdict(v) for v in banner_text_violations) + ][:20], "violations": { "before_consent": len(before_violations), "after_reject": len(reject_violations),