feat(cookie): Findings bearbeitbar — gruppiert nach Typ + Matrix + Hinweise-Split
CookieFindings: Umschalter [Nach Fehlertyp] (je Typ: Maßnahme + betroffene Cookies + Ticket-Text) ↔ [Matrix] (Cookie×Typ, ✗ Handlung / ⚠ Hinweis). Trennung FINDINGS (zu beheben) vs HINWEISE (neutral, gegen DSE zu prüfen). Backend: kind-Klassifikation (third_country/eu_alternative=hinweis); Drittland- Remediation neutral formuliert (pro Verarbeiter prüfen, keine 'in DSE benennen'- Befehle, da DSE-Abdeckung wie BMWs 'in der Regel SCC' oft unzureichend). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CookieFindings — bereitet die Library-Befunde bearbeitbar auf, statt als
|
||||||
|
* Fließtext-Liste. Zwei Sichten (Umschalter):
|
||||||
|
* - Nach Fehlertyp: je Typ eine Maßnahme + betroffene Cookies + Ticket-Text
|
||||||
|
* (= eine Ticket-Einheit). Getrennt in FINDINGS (zu beheben) und HINWEISE
|
||||||
|
* (neutral, gegen DSE zu prüfen: Drittland, EU-Alternative).
|
||||||
|
* - Matrix: Zeilen = Cookies, Spalten = Fehlertypen, Markierung wo nachzubessern
|
||||||
|
* ist (ein Cookie, alle Probleme auf einen Blick).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import type { CookieFinding } from './CookieLibraryPanel'
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
tracker_as_necessary: 'Tracker als „notwendig" deklariert',
|
||||||
|
missing_purpose: 'Zweck fehlt',
|
||||||
|
excessive_lifetime: 'Speicherdauer zu lang',
|
||||||
|
vague_duration: 'Speicherdauer nicht konkret',
|
||||||
|
missing_retention: 'Keine Speicherdauer/Löschfrist',
|
||||||
|
storage_transparency: 'Speichertyp nicht transparent',
|
||||||
|
third_country: 'Drittland-Transfer',
|
||||||
|
eu_alternative: 'EU-Alternative verfügbar',
|
||||||
|
}
|
||||||
|
const TYPE_MEASURE: Record<string, string> = {
|
||||||
|
tracker_as_necessary: 'Als einwilligungspflichtig einstufen (§ 25 Abs. 1 TDDDG).',
|
||||||
|
missing_purpose: 'Zweck je Cookie ergänzen (Art. 13 DSGVO).',
|
||||||
|
vague_duration: 'Konkrete Speicherdauer oder Löschkriterium angeben (Art. 5 Abs. 1 lit. e).',
|
||||||
|
missing_retention: 'Speicherdauer/Löschfrist je Verarbeiter festlegen (Art. 5 Abs. 1 lit. e).',
|
||||||
|
excessive_lifetime: 'Speicherdauer auf das Erforderliche reduzieren (Art. 5 Abs. 1 lit. e).',
|
||||||
|
storage_transparency: 'Speichertyp + -dauer je Objekt transparent ausweisen (§ 25 TDDDG).',
|
||||||
|
third_country: 'Geeignete Garantien je Verarbeiter prüfen (SCC Art. 46 / Art. 49).',
|
||||||
|
eu_alternative: 'EU-Alternative prüfen (kommerziell, kein Drittland-Transfer).',
|
||||||
|
}
|
||||||
|
const TYPE_ORDER = [
|
||||||
|
'tracker_as_necessary', 'missing_purpose', 'vague_duration', 'missing_retention',
|
||||||
|
'excessive_lifetime', 'storage_transparency', 'third_country', 'eu_alternative',
|
||||||
|
]
|
||||||
|
const SEV_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group { type: string; items: CookieFinding[]; severity: string }
|
||||||
|
|
||||||
|
function groupByType(findings: CookieFinding[]): Group[] {
|
||||||
|
const m = new Map<string, CookieFinding[]>()
|
||||||
|
for (const f of findings) {
|
||||||
|
if (!m.has(f.type)) m.set(f.type, [])
|
||||||
|
m.get(f.type)!.push(f)
|
||||||
|
}
|
||||||
|
const groups = [...m.entries()].map(([type, items]) => ({
|
||||||
|
type, items,
|
||||||
|
severity: items.reduce(
|
||||||
|
(s, f) => (SEV_ORDER[f.severity] ?? 3) < (SEV_ORDER[s] ?? 3) ? f.severity : s, 'LOW'),
|
||||||
|
}))
|
||||||
|
groups.sort((a, b) =>
|
||||||
|
(TYPE_ORDER.indexOf(a.type) + 99) % 100 - (TYPE_ORDER.indexOf(b.type) + 99) % 100)
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieLabel(f: CookieFinding): string {
|
||||||
|
const v = f.vendor && f.vendor !== '—' ? ` (${f.vendor})` : ''
|
||||||
|
const d = f.declared ? ` — ${f.declared}` : ''
|
||||||
|
return `${f.cookie}${v}${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketText(g: Group): string {
|
||||||
|
return [
|
||||||
|
`${TYPE_LABEL[g.type] || g.type} — ${g.items.length} betroffen`,
|
||||||
|
`Maßnahme: ${TYPE_MEASURE[g.type] || ''}`,
|
||||||
|
'',
|
||||||
|
...g.items.map(f => `- ${cookieLabel(f)}`),
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupCard({ g }: { g: Group }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const copy = () => {
|
||||||
|
navigator.clipboard?.writeText(ticketText(g)).then(() => {
|
||||||
|
setCopied(true); setTimeout(() => setCopied(false), 1500)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="border-b last:border-b-0">
|
||||||
|
<button onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 text-xs">
|
||||||
|
<span className={`text-gray-400 transition-transform ${open ? 'rotate-90' : ''}`}>›</span>
|
||||||
|
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${SEV_COLOR[g.severity] || 'bg-gray-100'}`}>
|
||||||
|
{g.severity}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-gray-800 flex-1 min-w-0 truncate">
|
||||||
|
{TYPE_LABEL[g.type] || g.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">{g.items.length}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 pb-3 space-y-2">
|
||||||
|
<div className="text-xs text-gray-700 bg-blue-50 rounded px-2 py-1.5">
|
||||||
|
<span className="font-semibold">Maßnahme:</span> {TYPE_MEASURE[g.type] || '—'}
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<tbody>
|
||||||
|
{g.items.map((f, i) => (
|
||||||
|
<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">{f.cookie}</td>
|
||||||
|
<td className="px-2 py-1 text-gray-400 w-32 truncate">{f.vendor}</td>
|
||||||
|
<td className="px-2 py-1 text-gray-500">{f.declared || ''}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button onClick={copy}
|
||||||
|
className="text-[11px] px-2 py-1 rounded bg-gray-100 text-gray-700 hover:bg-gray-200">
|
||||||
|
{copied ? '✓ Ticket-Text kopiert' : 'Ticket-Text kopieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, hint, groups }: { title: string; hint?: string; groups: Group[] }) {
|
||||||
|
if (!groups.length) return null
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-slate-50 border-b">
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{title}</span>
|
||||||
|
{hint && <span className="text-[10px] text-gray-400 ml-2">{hint}</span>}
|
||||||
|
</div>
|
||||||
|
{groups.map(g => <GroupCard key={g.type} g={g} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Matrix({ findings }: { findings: CookieFinding[] }) {
|
||||||
|
const { rows, cols } = useMemo(() => {
|
||||||
|
const colSet = new Set(findings.map(f => f.type))
|
||||||
|
const cols = TYPE_ORDER.filter(t => colSet.has(t))
|
||||||
|
const rowMap = new Map<string, { label: string; vendor: string; hits: Record<string, string> }>()
|
||||||
|
for (const f of findings) {
|
||||||
|
const key = `${f.cookie}@@${f.vendor}`
|
||||||
|
if (!rowMap.has(key)) rowMap.set(key, { label: f.cookie, vendor: f.vendor, hits: {} })
|
||||||
|
rowMap.get(key)!.hits[f.type] = (f.kind === 'hinweis') ? '⚠' : '✗'
|
||||||
|
}
|
||||||
|
return { rows: [...rowMap.values()], cols }
|
||||||
|
}, [findings])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-auto max-h-[32rem]">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="bg-slate-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold text-gray-600">Cookie</th>
|
||||||
|
{cols.map(c => (
|
||||||
|
<th key={c} className="px-1 py-1.5 text-center font-normal text-gray-500" title={TYPE_LABEL[c]}>
|
||||||
|
{(TYPE_LABEL[c] || c).split(' ')[0]}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i} className="border-t border-gray-100">
|
||||||
|
<td className="px-2 py-1 font-mono text-gray-700 break-all">
|
||||||
|
{r.label}
|
||||||
|
{r.vendor && r.vendor !== '—' && <span className="text-gray-400 ml-1">· {r.vendor}</span>}
|
||||||
|
</td>
|
||||||
|
{cols.map(c => (
|
||||||
|
<td key={c} className={`px-1 py-1 text-center ${r.hits[c] === '✗' ? 'text-red-600' : r.hits[c] === '⚠' ? 'text-amber-600' : 'text-gray-200'}`}>
|
||||||
|
{r.hits[c] || '·'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="px-2 py-1.5 text-[10px] text-gray-400 border-t">
|
||||||
|
✗ = Handlung nötig · ⚠ = Hinweis (zu prüfen) · Spalte = Fehlertyp (Tooltip)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CookieFindings({ findings }: { findings: CookieFinding[] }) {
|
||||||
|
const [mode, setMode] = useState<'type' | 'matrix'>('type')
|
||||||
|
const real = findings.filter(f => (f.kind ?? 'finding') !== 'hinweis')
|
||||||
|
const hints = findings.filter(f => (f.kind ?? 'finding') === 'hinweis')
|
||||||
|
|
||||||
|
if (!findings.length) {
|
||||||
|
return <div className="px-4 py-3 text-sm text-green-700 border rounded-lg">Keine Abweichungen gegen die Library.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = (m: 'type' | 'matrix', label: string) => (
|
||||||
|
<button onClick={() => setMode(m)}
|
||||||
|
className={`px-2.5 py-1 rounded text-xs ${mode === m ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
|
{findings.length} Befund{findings.length !== 1 ? 'e' : ''}
|
||||||
|
<span className="text-xs font-normal text-gray-400 ml-2">
|
||||||
|
{real.length} zu beheben · {hints.length} Hinweise
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{btn('type', 'Nach Fehlertyp')}
|
||||||
|
{btn('matrix', 'Matrix')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'matrix' ? (
|
||||||
|
<Matrix findings={findings} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Section title="Findings — zu beheben" groups={groupByType(real)} />
|
||||||
|
<Section title="Hinweise — neutral, gegen DSE/Doku zu prüfen"
|
||||||
|
hint="z.B. Drittland: interne Verträge können wir nicht einsehen"
|
||||||
|
groups={groupByType(hints)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { CookieFindings } from './CookieFindings'
|
||||||
|
|
||||||
export interface CookieFinding {
|
export interface CookieFinding {
|
||||||
vendor: string
|
vendor: string
|
||||||
cookie: string
|
cookie: string
|
||||||
@@ -16,6 +18,7 @@ export interface CookieFinding {
|
|||||||
declared: string
|
declared: string
|
||||||
library_purpose: string
|
library_purpose: string
|
||||||
remediation: string
|
remediation: string
|
||||||
|
kind?: string
|
||||||
control?: { control_id?: string | null; regulation?: string; article?: string }
|
control?: { control_id?: string | null; regulation?: string; article?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,21 +39,6 @@ interface CheckData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
|
||||||
vague_duration: 'Speicherdauer nicht konkret',
|
|
||||||
missing_retention: 'Keine Speicherdauer/Löschfrist',
|
|
||||||
third_country: 'Drittland-Transfer',
|
|
||||||
eu_alternative: 'EU-Alternative verfügbar',
|
|
||||||
storage_transparency: 'Speichertyp nicht transparent',
|
|
||||||
}
|
|
||||||
const STORAGE_LABEL: Record<string, string> = {
|
const STORAGE_LABEL: Record<string, string> = {
|
||||||
cookie: 'Cookies', local_storage: 'Local Storage',
|
cookie: 'Cookies', local_storage: 'Local Storage',
|
||||||
session_storage: 'Session Storage', indexeddb: 'IndexedDB',
|
session_storage: 'Session Storage', indexeddb: 'IndexedDB',
|
||||||
@@ -66,75 +54,45 @@ export function CookieFindingList({ data }: { data: CheckData }) {
|
|||||||
const driftShown =
|
const driftShown =
|
||||||
!!drift && ((drift.declared_count ?? 0) + (drift.browser_count ?? 0)) > 0
|
!!drift && ((drift.declared_count ?? 0) + (drift.browser_count ?? 0)) > 0
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="space-y-3">
|
||||||
{driftShown && (
|
{(driftShown || (inv && (inv.total ?? 0) > 0)) && (
|
||||||
<div className="px-4 py-2.5 bg-amber-50 border-b text-xs text-amber-900">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<span className="font-semibold">Richtlinie ↔ Realität:</span>{' '}
|
{driftShown && (
|
||||||
<strong>{drift!.declared_count ?? 0}</strong> in der Cookie-Richtlinie
|
<div className="px-4 py-2.5 bg-amber-50 border-b text-xs text-amber-900">
|
||||||
dokumentiert · <strong>{drift!.browser_count ?? 0}</strong> im Browser geladen
|
<span className="font-semibold">Richtlinie ↔ Realität:</span>{' '}
|
||||||
{(drift!.high_findings ?? 0) > 0 && (
|
<strong>{drift!.declared_count ?? 0}</strong> in der Cookie-Richtlinie
|
||||||
<> · <strong className="text-red-700">{drift!.high_findings} undokumentiert geladen</strong></>
|
dokumentiert · <strong>{drift!.browser_count ?? 0}</strong> im Browser geladen
|
||||||
)}
|
{(drift!.high_findings ?? 0) > 0 && (
|
||||||
{(drift!.low_findings ?? 0) > 0 && (
|
<> · <strong className="text-red-700">{drift!.high_findings} undokumentiert geladen</strong></>
|
||||||
<> · {drift!.low_findings} dokumentiert, aber nicht geladen</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{inv && (inv.total ?? 0) > 0 && (
|
|
||||||
<div className="px-4 py-2.5 bg-blue-50 border-b text-xs text-blue-900">
|
|
||||||
<span className="font-semibold">Storage-Inventar:</span>{' '}
|
|
||||||
{inv.total} als „Cookies" gelistet →{' '}
|
|
||||||
<strong>{inv.real_cookies} echte Cookies</strong>
|
|
||||||
{(inv.other_storage ?? 0) > 0 && (
|
|
||||||
<> + <strong className="text-amber-700">{inv.other_storage} andere Endgeräte-Speicher</strong></>
|
|
||||||
)}
|
|
||||||
{inv.by_type && (
|
|
||||||
<span className="text-blue-700 ml-1">
|
|
||||||
({Object.entries(inv.by_type)
|
|
||||||
.map(([k, n]) => `${n} ${STORAGE_LABEL[k] || k}`)
|
|
||||||
.join(' · ')})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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>
|
{(drift!.low_findings ?? 0) > 0 && (
|
||||||
{f.control?.regulation && f.control.regulation !== '—' && (
|
<> · {drift!.low_findings} dokumentiert, aber nicht geladen</>
|
||||||
<div className="text-[10px] text-gray-400">
|
|
||||||
Rechtsgrundlage: {f.control.regulation} {f.control.article}
|
|
||||||
{f.control.control_id && ` · Control ${f.control.control_id}`}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
{inv && (inv.total ?? 0) > 0 && (
|
||||||
|
<div className="px-4 py-2.5 bg-blue-50 text-xs text-blue-900">
|
||||||
|
<span className="font-semibold">Storage-Inventar:</span>{' '}
|
||||||
|
{inv.total} als „Cookies" gelistet →{' '}
|
||||||
|
<strong>{inv.real_cookies} echte Cookies</strong>
|
||||||
|
{(inv.other_storage ?? 0) > 0 && (
|
||||||
|
<> + <strong className="text-amber-700">{inv.other_storage} andere Endgeräte-Speicher</strong></>
|
||||||
|
)}
|
||||||
|
{inv.by_type && (
|
||||||
|
<span className="text-blue-700 ml-1">
|
||||||
|
({Object.entries(inv.by_type)
|
||||||
|
.map(([k, n]) => `${n} ${STORAGE_LABEL[k] || k}`)
|
||||||
|
.join(' · ')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="text-[11px] text-gray-400">
|
||||||
|
{s.in_library ?? 0}/{s.checked ?? 0} Cookies in der Library erkannt
|
||||||
|
</div>
|
||||||
|
<CookieFindings findings={findings} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
|
||||||
|
import { CookieFindings } from '../CookieFindings'
|
||||||
|
|
||||||
|
const FINDINGS = [
|
||||||
|
{ vendor: 'Salesforce', cookie: '_ga', type: 'tracker_as_necessary', severity: 'HIGH',
|
||||||
|
declared: 'necessary', library_purpose: '', remediation: '', kind: 'finding' },
|
||||||
|
{ vendor: 'Acme', cookie: 'foo', type: 'missing_purpose', severity: 'MEDIUM',
|
||||||
|
declared: '', library_purpose: '', remediation: '', kind: 'finding' },
|
||||||
|
{ vendor: 'Google', cookie: '_gid', type: 'third_country', severity: 'MEDIUM',
|
||||||
|
declared: 'US', library_purpose: '', remediation: '', kind: 'hinweis' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('CookieFindings', () => {
|
||||||
|
it('gruppiert nach Typ und trennt Findings von Hinweisen', () => {
|
||||||
|
render(<CookieFindings findings={FINDINGS} />)
|
||||||
|
expect(screen.getByText(/3 Befunde/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Findings — zu beheben/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Hinweise — neutral/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Tracker als/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Drittland-Transfer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('klappt eine Gruppe auf und zeigt Maßnahme + Ticket-Button', () => {
|
||||||
|
render(<CookieFindings findings={FINDINGS} />)
|
||||||
|
fireEvent.click(screen.getByText(/Zweck fehlt/))
|
||||||
|
expect(screen.getByText(/Maßnahme:/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Ticket-Text kopieren/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schaltet auf die Matrix-Sicht um', () => {
|
||||||
|
render(<CookieFindings findings={FINDINGS} />)
|
||||||
|
fireEvent.click(screen.getByText('Matrix'))
|
||||||
|
expect(screen.getByText(/Handlung nötig/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zeigt grünen Hinweis bei 0 Befunden', () => {
|
||||||
|
render(<CookieFindings findings={[]} />)
|
||||||
|
expect(screen.getByText(/Keine Abweichungen/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,21 +4,20 @@ import { render, screen } from '@testing-library/react'
|
|||||||
import { CookieFindingList } from '../CookieLibraryPanel'
|
import { CookieFindingList } from '../CookieLibraryPanel'
|
||||||
|
|
||||||
describe('CookieFindingList', () => {
|
describe('CookieFindingList', () => {
|
||||||
it('zeigt Befunde mit Severity, Library-Zweck + Maßnahme', () => {
|
it('zeigt Befunde gruppiert nach Typ mit Severity + Library-Count', () => {
|
||||||
const data = {
|
const data = {
|
||||||
summary: { checked: 10, in_library: 4, findings: 1 },
|
summary: { checked: 10, in_library: 4, findings: 1 },
|
||||||
findings: [{
|
findings: [{
|
||||||
vendor: 'Salesforce', cookie: '_ga', type: 'tracker_as_necessary',
|
vendor: 'Salesforce', cookie: '_ga', type: 'tracker_as_necessary',
|
||||||
severity: 'HIGH', declared: 'necessary',
|
severity: 'HIGH', declared: 'necessary',
|
||||||
library_purpose: 'Besucher eindeutig unterscheiden',
|
library_purpose: 'Besucher eindeutig unterscheiden',
|
||||||
remediation: 'Als einwilligungspflichtig (§ 25 TDDDG) einstufen.',
|
remediation: 'Als einwilligungspflichtig (§ 25 TDDDG) einstufen.', kind: 'finding',
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
render(<CookieFindingList data={data} />)
|
render(<CookieFindingList data={data} />)
|
||||||
expect(screen.getByText(/1 Befund/)).toBeInTheDocument()
|
expect(screen.getByText(/1 Befund/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('_ga')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('HIGH')).toBeInTheDocument()
|
expect(screen.getByText('HIGH')).toBeInTheDocument()
|
||||||
expect(screen.getByText(/§ 25 TDDDG/)).toBeInTheDocument()
|
expect(screen.getByText(/Tracker als/)).toBeInTheDocument() // Gruppen-Header
|
||||||
expect(screen.getByText(/4\/10 Cookies/)).toBeInTheDocument()
|
expect(screen.getByText(/4\/10 Cookies/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ _CONTROL_MAP = {
|
|||||||
"eu_alternative": {"control_id": None, "regulation": "—", "article": "kommerzielle Empfehlung"},
|
"eu_alternative": {"control_id": None, "regulation": "—", "article": "kommerzielle Empfehlung"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Advisory-Typen: keine bestätigten Verstöße, sondern Hinweise, die der
|
||||||
|
# Cross-Finding-Agent gegen die DSE abgleicht (Drittland kann dort bereits via
|
||||||
|
# SCC/Art. 49/Angemessenheit abgedeckt sein → dann unterdrücken).
|
||||||
|
_HINWEIS_TYPES = {"third_country", "eu_alternative"}
|
||||||
|
|
||||||
|
|
||||||
def load_big_library(db, names: list[str]) -> dict:
|
def load_big_library(db, names: list[str]) -> dict:
|
||||||
"""Batch-Lookup der grossen Open-Cookie-Database (compliance.cookie_library,
|
"""Batch-Lookup der grossen Open-Cookie-Database (compliance.cookie_library,
|
||||||
@@ -222,9 +227,13 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict:
|
|||||||
"declared": country or "—",
|
"declared": country or "—",
|
||||||
"library_purpose": schrems or f"Anbieter-Sitz {country}",
|
"library_purpose": schrems or f"Anbieter-Sitz {country}",
|
||||||
"remediation": (
|
"remediation": (
|
||||||
f"{vname} überträgt in ein Drittland ({country or 'außerhalb EWR'}) — "
|
f"Neutrales Finding: {vname} kann Daten außerhalb der EU "
|
||||||
f"SCC (Art. 46) oder DPF-Zertifizierung prüfen und in der "
|
f"({country or 'Drittland'}) verarbeiten. Für jeden solchen "
|
||||||
f"Datenschutzerklärung benennen (Art. 44 ff. DSGVO)."
|
f"Verarbeiter geeignete Garantien konkret nachweisen (SCC Art. 46 / "
|
||||||
|
f"Angemessenheitsbeschluss / Art. 49) und ggf. eine "
|
||||||
|
f"Transfer-Folgenabschätzung (TIA). Pauschale DSE-Formulierungen "
|
||||||
|
f"('in der Regel SCC') genügen nicht — pro Verarbeiter prüfen "
|
||||||
|
f"(Art. 44 ff. DSGVO). Interne Verträge können wir nicht einsehen."
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -262,9 +271,11 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict:
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
# A: jeden Befund an seinen Control + Rechtsgrundlage haengen (auditfest).
|
# A: jeden Befund an Control + Rechtsgrundlage haengen + als echtes Finding
|
||||||
|
# (zu beheben) oder Hinweis (advisory, gegen DSE abzugleichen) klassifizieren.
|
||||||
for f in findings:
|
for f in findings:
|
||||||
f["control"] = _CONTROL_MAP.get(f["type"], {})
|
f["control"] = _CONTROL_MAP.get(f["type"], {})
|
||||||
|
f["kind"] = "hinweis" if f["type"] in _HINWEIS_TYPES else "finding"
|
||||||
findings.sort(key=lambda f: _SEV_ORDER.get(f["severity"], 3))
|
findings.sort(key=lambda f: _SEV_ORDER.get(f["severity"], 3))
|
||||||
return {
|
return {
|
||||||
"summary": {
|
"summary": {
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ def test_third_country_and_eu_alternative_for_us_tracker():
|
|||||||
assert "eu_alternative" in t
|
assert "eu_alternative" in t
|
||||||
|
|
||||||
|
|
||||||
|
def test_kind_splits_findings_from_hinweise():
|
||||||
|
# third_country/eu_alternative = Hinweis (advisory); Rest = Finding.
|
||||||
|
out = analyze_cookies([{
|
||||||
|
"name": "Google", "category": "necessary",
|
||||||
|
"cookies": [{"name": "_ga", "purpose": "", "expiry": "2 Jahre"}],
|
||||||
|
}])
|
||||||
|
by = {f["type"]: f["kind"] for f in out["findings"]}
|
||||||
|
assert by.get("third_country") == "hinweis"
|
||||||
|
assert by.get("eu_alternative") == "hinweis"
|
||||||
|
assert by.get("tracker_as_necessary") == "finding"
|
||||||
|
# Drittland-Wording: neutral, pro Verarbeiter, keine "in DSE benennen"-Befehle.
|
||||||
|
tc = next(f for f in out["findings"] if f["type"] == "third_country")
|
||||||
|
assert "pro Verarbeiter" in tc["remediation"]
|
||||||
|
assert "benennen" not in tc["remediation"]
|
||||||
|
|
||||||
|
|
||||||
def test_third_country_deduped_per_vendor():
|
def test_third_country_deduped_per_vendor():
|
||||||
out = analyze_cookies([{
|
out = analyze_cookies([{
|
||||||
"name": "Google", "category": "marketing",
|
"name": "Google", "category": "marketing",
|
||||||
|
|||||||
Reference in New Issue
Block a user