feat(admin): Tab "Browser-Verhalten" — Per-Browser-Matrix + Screenshots (Phase 3)

- 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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-12 23:15:06 +02:00
parent c7fde93061
commit 9587726936
6 changed files with 359 additions and 0 deletions
@@ -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 },
)
}
}
@@ -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 },
)
}
}
@@ -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<string, unknown>; 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<Matrix | null>(null)
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sel, setSel] = useState<string>('')
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 <div className="text-sm text-gray-500">Lade Browser-Verhalten</div>
if (!matrix || !rows.length) {
return (
<div className="border border-gray-200 rounded-xl p-5 space-y-3 bg-gray-50">
<h3 className="font-semibold text-gray-900">Browser-Verhalten testen</h3>
<p className="text-sm text-gray-600 max-w-2xl">
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 <strong>vor</strong> der Einwilligung gesetzt, und werden sie
nach <strong>Ablehnen"</strong> wirklich entfernt? Dazu eine
Oberflächenanalyse (Impressum-/DSE-Links, Banner-Auffälligkeiten) mit
Screenshot je Engine.
</p>
<p className="text-xs text-gray-400">
Der Test crawlt die Seite live und dauert je nach Browser-Anzahl
einige Minuten.
</p>
{error && <div className="text-sm text-red-600">{error}</div>}
<button onClick={run} disabled={running}
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
{running ? 'Test läuft… (bitte warten)' : 'Browser-Test starten'}
</button>
</div>
)
}
const selRow = rows.find(r => r.profile_id === sel) || rows[0]
const agg: Record<string, unknown> = matrix.aggregate || {}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-xs text-gray-500">
{matrix.scanned_at ? `Test vom ${String(matrix.scanned_at).slice(0, 16).replace('T', ' ')}` : ''}
{agg.profiles_run ? ` · ${String(agg.profiles_run)} Browser` : ''}
{' · '}<span className="text-gray-400">Live-Messung, kann von der Snapshot-Zeit abweichen</span>
</div>
<button onClick={run} disabled={running}
className="px-3 py-1.5 text-sm rounded-lg border border-blue-200 text-blue-700 hover:bg-blue-50 disabled:opacity-50">
{running ? 'läuft…' : 'Erneut testen'}
</button>
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<div className="overflow-x-auto border border-gray-200 rounded-xl">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-xs">
<tr>
<th className="text-left px-3 py-2">Browser</th>
<th className="px-3 py-2">Cookies vor Consent</th>
<th className="px-3 py-2">Cookies nach Ablehnen</th>
<th className="px-3 py-2">Ablehnen respektiert</th>
<th className="px-3 py-2">Oberfläche</th>
<th className="px-3 py-2">Score</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={r.profile_id} onClick={() => setSel(r.profile_id)}
className={`border-t border-gray-100 cursor-pointer ${sld ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
<td className="px-3 py-2 text-left">
{r.label}
{r.is_mobile && <span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700">Mobil</span>}
</td>
{r.error || !s ? (
<td colSpan={4} className="px-3 py-2 text-center text-gray-400 text-xs">
nicht verfügbar{r.error ? ` (${r.error.slice(0, 40)})` : ''}
</td>
) : (
<>
<td className={`px-3 py-2 text-center ${(before ?? 0) > 0 ? 'text-red-700 font-semibold' : 'text-green-700'}`}>{before}</td>
<td className={`px-3 py-2 text-center ${(after ?? 0) > 0 ? 'text-amber-700' : 'text-green-700'}`}>{after}</td>
<td className="px-3 py-2 text-center">
{s.reject_respected ? <span className="text-green-700">✓</span> : <span className="text-red-700 font-semibold">✗</span>}
</td>
<td className="px-3 py-2 text-center text-xs">
{!s.surface?.has_impressum_link && <span className="text-amber-700">Impressum fehlt </span>}
{!s.surface?.has_dse_link && <span className="text-amber-700">DSE fehlt </span>}
{(s.surface?.banner_text_issues ?? 0) > 0
? <span className="text-gray-600">{s.surface?.banner_text_issues} Hinweis(e)</span>
: (s.surface?.has_impressum_link && s.surface?.has_dse_link ? <span className="text-green-700">ok</span> : null)}
</td>
</>
)}
<td className={`px-3 py-2 text-center font-semibold ${scoreCls(r.score)}`}>{r.score ?? ''}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{selRow && (
<div className="border border-gray-200 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900">{selRow.label}</h3>
{selRow.verbal && <span className="text-xs text-gray-500">· {selRow.verbal}</span>}
</div>
{selRow.summary?.banner_screenshot_b64 ? (
<img alt={`Banner ${selRow.label}`}
src={`data:image/png;base64,${selRow.summary.banner_screenshot_b64}`}
className="max-h-80 rounded-lg border border-gray-200" />
) : (
<div className="text-xs text-gray-400">Kein Banner-Screenshot erfasst.</div>
)}
{(selRow.summary?.banner_findings?.length ?? 0) > 0 ? (
<ul className="space-y-1.5">
{selRow.summary!.banner_findings!.map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className={`text-[10px] px-1.5 py-0.5 rounded uppercase ${sevCls(f.severity)}`}>{f.severity || 'INFO'}</span>
<span className="text-gray-700">
{f.text}{f.legal_ref && <span className="text-gray-400"> · {f.legal_ref}</span>}
</span>
</li>
))}
</ul>
) : selRow.summary ? (
<div className="text-sm text-green-700">Keine Oberflächen-Auffälligkeiten in dieser Engine.</div>
) : null}
</div>
)}
</div>
)
}
@@ -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(<BrowserBehaviorView snapshotId="abc" />)
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(<BrowserBehaviorView snapshotId="abc" />)
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()
})
})
@@ -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' && (
<AgentModuleTab snapshotId={snapshotId} docType="agb" label="AGB" />
)}
{tab === 'browser' && (
<BrowserBehaviorView snapshotId={snapshotId} />
)}
</>
)}
</div>
@@ -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),