feat(cookie): Pro-Cookie-Library-Abgleich (2287er OCD + 35er rich) + Panel
- analyze_cookies gleicht Cookies gegen BEIDE Libraries ab: compliance.cookie_library
(2287, OCD/CC0 — Kategorie/Retention) + 35er rich-DB (technical_necessity/reid/
schrems/eu_alternative). 5 Befund-Typen: tracker_as_necessary, missing_purpose,
excessive_lifetime (Art.5), third_country (Art.44), eu_alternative (kommerziell).
- Endpoint GET /snapshots/{id}/cookie-check (load_big_library batch + analyze).
- Frontend CookieLibraryPanel im Snapshot-Detail.
- Fix CookieResultView: Zweck nicht mehr auf 60 Zeichen gekuerzt; Rolle 'unknown'
als Strich statt 'Unbekannt'.
Tests: 7 backend + frontend vitest gruen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* CookieLibraryPanel — Pro-Cookie-Abgleich gegen die Knowledge-Library:
|
||||
* findet als „notwendig" deklarierte Tracker + fehlende Zwecke und zeigt je
|
||||
* Befund die Abstellmaßnahme. Lädt aus dem Snapshot (kein Re-Crawl).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export interface CookieFinding {
|
||||
vendor: string
|
||||
cookie: string
|
||||
type: string
|
||||
severity: string
|
||||
declared: string
|
||||
library_purpose: string
|
||||
remediation: string
|
||||
}
|
||||
|
||||
interface CheckData {
|
||||
summary?: { checked?: number; in_library?: number; findings?: number }
|
||||
findings?: CookieFinding[]
|
||||
}
|
||||
|
||||
const SEV_COLOR: Record<string, string> = {
|
||||
HIGH: 'bg-red-100 text-red-700',
|
||||
MEDIUM: 'bg-amber-100 text-amber-700',
|
||||
LOW: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
tracker_as_necessary: 'Tracker als „notwendig" deklariert',
|
||||
missing_purpose: 'Zweck fehlt',
|
||||
excessive_lifetime: 'Speicherdauer zu lang',
|
||||
third_country: 'Drittland-Transfer',
|
||||
eu_alternative: 'EU-Alternative verfügbar',
|
||||
}
|
||||
|
||||
// Pure, testbar.
|
||||
export function CookieFindingList({ data }: { data: CheckData }) {
|
||||
const findings = data.findings || []
|
||||
const s = data.summary || {}
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-slate-50 border-b text-sm font-semibold text-gray-800">
|
||||
Library-Abgleich — {findings.length} Befund{findings.length !== 1 ? 'e' : ''}
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">
|
||||
{s.in_library ?? 0}/{s.checked ?? 0} Cookies in der Library erkannt
|
||||
</span>
|
||||
</div>
|
||||
{findings.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-green-700">
|
||||
Keine Abweichungen gegen die Library.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 max-h-96 overflow-auto">
|
||||
{findings.map((f, i) => (
|
||||
<div key={i} className="px-4 py-2.5 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${SEV_COLOR[f.severity] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{f.severity}
|
||||
</span>
|
||||
<code className="text-xs text-gray-700">{f.cookie}</code>
|
||||
<span className="text-xs text-gray-400">· {f.vendor}</span>
|
||||
<span className="text-[10px] text-gray-500 ml-auto">
|
||||
{TYPE_LABEL[f.type] || f.type}
|
||||
</span>
|
||||
</div>
|
||||
{f.library_purpose && (
|
||||
<div className="text-xs text-gray-500">Library-Zweck: {f.library_purpose}</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-700">{f.remediation}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CookieLibraryPanel({ snapshotId }: { snapshotId: string }) {
|
||||
const [data, setData] = useState<CheckData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/cookie-check`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!cancelled) setData(d) })
|
||||
.catch(() => { if (!cancelled) setData({ findings: [] }) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [snapshotId])
|
||||
|
||||
if (loading) return <div className="text-xs text-gray-400">Library-Abgleich läuft…</div>
|
||||
return <CookieFindingList data={data || {}} />
|
||||
}
|
||||
@@ -112,11 +112,19 @@ function VendorRow({ v }: { v: SnapshotVendor }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{cookies.map((c, i) => (
|
||||
<tr key={i} className="border-t border-gray-100">
|
||||
<td className="px-2 py-1 font-mono text-gray-700 break-all">{c.name}</td>
|
||||
<td className="px-2 py-1 text-gray-500">{ROLE_LABEL[c.functional_role || 'unknown'] || c.functional_role}</td>
|
||||
<td className="px-2 py-1 text-gray-500">{c.purpose ? c.purpose.slice(0, 60) : <span className="text-amber-600 italic">kein Zweck</span>}</td>
|
||||
<td className="px-2 py-1 text-gray-400">{c.expiry || '—'}</td>
|
||||
<tr key={i} className="border-t border-gray-100 align-top">
|
||||
<td className="px-2 py-1 font-mono text-gray-700 break-all w-40">{c.name}</td>
|
||||
<td className="px-2 py-1 text-gray-500 w-24">
|
||||
{c.functional_role && c.functional_role !== 'unknown'
|
||||
? (ROLE_LABEL[c.functional_role] || c.functional_role)
|
||||
: <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-gray-500 break-words">
|
||||
{c.purpose
|
||||
? c.purpose
|
||||
: <span className="text-amber-600 italic">kein Zweck</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-gray-400 w-24 whitespace-nowrap">{c.expiry || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import { CookieFindingList } from '../CookieLibraryPanel'
|
||||
|
||||
describe('CookieFindingList', () => {
|
||||
it('zeigt Befunde mit Severity, Library-Zweck + Maßnahme', () => {
|
||||
const data = {
|
||||
summary: { checked: 10, in_library: 4, findings: 1 },
|
||||
findings: [{
|
||||
vendor: 'Salesforce', cookie: '_ga', type: 'tracker_as_necessary',
|
||||
severity: 'HIGH', declared: 'necessary',
|
||||
library_purpose: 'Besucher eindeutig unterscheiden',
|
||||
remediation: 'Als einwilligungspflichtig (§ 25 TDDDG) einstufen.',
|
||||
}],
|
||||
}
|
||||
render(<CookieFindingList data={data} />)
|
||||
expect(screen.getByText(/1 Befund/)).toBeInTheDocument()
|
||||
expect(screen.getByText('_ga')).toBeInTheDocument()
|
||||
expect(screen.getByText('HIGH')).toBeInTheDocument()
|
||||
expect(screen.getByText(/§ 25 TDDDG/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/4\/10 Cookies/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('zeigt grünen Hinweis bei 0 Befunden', () => {
|
||||
render(<CookieFindingList data={{ summary: { checked: 5, in_library: 2 }, findings: [] }} />)
|
||||
expect(screen.getByText(/Keine Abweichungen/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@
|
||||
import React, { use as useUnwrap, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel'
|
||||
import { CookieResultView } from '../../_components/CookieResultView'
|
||||
|
||||
export default function SnapshotDetail(
|
||||
@@ -45,7 +46,10 @@ export default function SnapshotDetail(
|
||||
) : error || !snap ? (
|
||||
<div className="text-sm text-red-600">Snapshot nicht gefunden.</div>
|
||||
) : hasCookies ? (
|
||||
<CookieResultView snapshot={snap} />
|
||||
<>
|
||||
<CookieLibraryPanel snapshotId={snapshotId} />
|
||||
<CookieResultView snapshot={snap} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">
|
||||
Dieser Snapshot enthält keine Cookie-/Vendor-Daten.
|
||||
|
||||
Reference in New Issue
Block a user