Compare commits
5 Commits
7c17321089
...
608fb7faf5
| Author | SHA1 | Date | |
|---|---|---|---|
| 608fb7faf5 | |||
| 78d7273b82 | |||
| 969658261f | |||
| 58a3fb285f | |||
| 313ee5073b |
+27
-7
@@ -139,13 +139,28 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
finally { setSaving(null) }
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const [page, setPage] = useState(0)
|
||||
const sorted = [...hazards].sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
const totalPages = Math.ceil(sorted.length / PAGE_SIZE)
|
||||
const paged = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Risikobewertungstabelle (ISO 12100)</h2>
|
||||
<span className="text-xs text-gray-500">{hazards.length} Gefaehrdungen</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
|
||||
className="px-2 py-1 rounded border border-gray-200 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed"><</button>
|
||||
<span className="px-2 text-gray-600">Seite {page + 1} / {totalPages}</span>
|
||||
<button onClick={() => setPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}
|
||||
className="px-2 py-1 rounded border border-gray-200 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed">></button>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{hazards.length} Gefaehrdungen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -185,15 +200,20 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{sorted.map(h => {
|
||||
{paged.map(h => {
|
||||
const ra = (h as Record<string, unknown>).risk_assessment as Record<string, number> | null
|
||||
const initS = ra?.severity || h.severity || 3
|
||||
const initE = ra?.exposure || h.exposure || 3
|
||||
const initP = ra?.probability || h.probability || 3
|
||||
const initA = h.avoidance || 0
|
||||
const e = edits[h.id]
|
||||
const initRpz = h.r_inherent || rpz(h.severity, h.exposure, h.probability, h.avoidance)
|
||||
const initRpz = rpz(initS, initE, initP, initA)
|
||||
const afterRpz = e ? rpz(e.severity, e.exposure, e.probability, e.avoidance) : initRpz
|
||||
const afterLevel = getRiskLevelISO(afterRpz)
|
||||
const sil = silFromRpz(afterRpz)
|
||||
const pl = plFromRpz(afterRpz)
|
||||
const mc = mitCounts[h.id] || 0
|
||||
const changed = e && (e.severity !== h.severity || e.exposure !== h.exposure || e.probability !== h.probability || e.avoidance !== (h.avoidance || 3))
|
||||
const changed = e && (e.severity !== initS || e.exposure !== initE || e.probability !== initP)
|
||||
|
||||
return (
|
||||
<tr key={h.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
@@ -208,9 +228,9 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
|
||||
</span>
|
||||
</td>
|
||||
{/* Initial S/E/P/RPZ/Risk */}
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.severity}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.exposure}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.probability}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{initS}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{initE}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{initP}</td>
|
||||
<td className="px-2 py-2 text-center font-bold text-gray-900 dark:text-white">{initRpz}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-gray-200 dark:border-gray-600">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${getRiskColor(h.risk_level)}`}>
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function MitigationsPage() {
|
||||
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
||||
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
||||
|
||||
@@ -183,8 +184,8 @@ export default function MitigationsPage() {
|
||||
<div>Gefaehrdung</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{items.map((m) => (
|
||||
{/* Rows — paginated */}
|
||||
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => (
|
||||
<div key={m.id}
|
||||
className={`grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
||||
<div className="pt-0.5">
|
||||
@@ -203,6 +204,12 @@ export default function MitigationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length > (mitPages[type] || 1) * 50 && (
|
||||
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
|
||||
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
|
||||
Weitere {Math.min(50, items.length - (mitPages[type] || 1) * 50)} von {items.length} laden...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+26
-7
@@ -14,6 +14,12 @@ export function SuggestEvidenceModal({
|
||||
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
|
||||
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filtered = search.trim()
|
||||
? mitigations.filter(m => (m.title || '').toLowerCase().includes(search.toLowerCase()))
|
||||
: mitigations
|
||||
const displayed = filtered.slice(0, 20) // Show max 20 at a time
|
||||
|
||||
async function handleSelectMitigation(mitigationId: string) {
|
||||
setSelectedMitigation(mitigationId)
|
||||
@@ -41,22 +47,35 @@ export function SuggestEvidenceModal({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Waehlen Sie eine Massnahme ({mitigations.length} gesamt). Suchen Sie nach Name:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mitigations.map(m => (
|
||||
<input
|
||||
type="text" value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Massnahme suchen..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<div className="max-h-[200px] overflow-auto space-y-1">
|
||||
{displayed.map(m => (
|
||||
<button
|
||||
key={m.id} onClick={() => handleSelectMitigation(m.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
className={`w-full text-left px-3 py-2 text-xs rounded-lg border transition-colors ${
|
||||
selectedMitigation === m.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
|
||||
: 'border-gray-100 bg-white text-gray-700 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{m.title}
|
||||
{m.title || '(Ohne Titel)'}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length > 20 && (
|
||||
<p className="text-xs text-gray-400 text-center py-1">
|
||||
{filtered.length - 20} weitere — Suchbegriff eingeben um zu filtern
|
||||
</p>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-2">Keine Massnahmen gefunden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
|
||||
@@ -23,18 +23,25 @@ export default function VerificationPage() {
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [verRes, hazRes, mitRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
])
|
||||
// Only load verifications initially — hazards/mitigations loaded on demand
|
||||
const verRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`)
|
||||
if (verRes.ok) { const j = await verRes.json(); setItems(j.verifications || j || []) }
|
||||
if (hazRes.ok) { const j = await hazRes.json(); setHazards((j.hazards || j || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name }))) }
|
||||
if (mitRes.ok) { const j = await mitRes.json(); setMitigations((j.mitigations || j || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title }))) }
|
||||
} catch (err) { console.error('Failed to fetch data:', err) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function loadMitigationsIfNeeded() {
|
||||
if (mitigations.length > 0) return
|
||||
try {
|
||||
const mitRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
|
||||
if (mitRes.ok) {
|
||||
const j = await mitRes.json()
|
||||
const mits = (j.mitigations || j || []).map((m: Record<string, string>) => ({ id: m.id, title: m.title || m.name || '' }))
|
||||
setMitigations(mits)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleSubmit(data: VerificationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
@@ -89,8 +96,8 @@ export default function VerificationPage() {
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{mitigations.length > 0 && (
|
||||
<button onClick={() => setShowSuggest(true)} className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
|
||||
{true && (
|
||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
@@ -147,7 +154,7 @@ export default function VerificationPage() {
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{mitigations.length > 0 && (
|
||||
<button onClick={() => setShowSuggest(true)} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
||||
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
||||
Nachweise vorschlagen
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -100,6 +100,15 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const projectId = params?.projectId as string | undefined
|
||||
const [projectName, setProjectName] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectId) return
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.machine_name) setProjectName(d.machine_name) })
|
||||
.catch(() => {})
|
||||
}, [projectId])
|
||||
|
||||
const basePath = projectId ? `/sdk/iace/${projectId}` : ''
|
||||
|
||||
@@ -127,9 +136,12 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
</svg>
|
||||
Alle Projekte
|
||||
</Link>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mt-2">
|
||||
CE-Compliance
|
||||
</h2>
|
||||
{projectName && (
|
||||
<p className="text-xs font-bold text-purple-700 dark:text-purple-400 mt-2 truncate" title={projectName}>
|
||||
{projectName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">CE-Compliance</p>
|
||||
<Link
|
||||
href="/sdk/iace/lines"
|
||||
className="mt-2 flex items-center gap-1.5 text-xs text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 transition-colors"
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* IACE (CE-Compliance) Module — New Feature E2E Tests
|
||||
*
|
||||
* Covers: Order, Interview (Grenzen & Verwendung), Compliance Alerts,
|
||||
* Risk Assessment Table, Mitigations batch actions, CE-Akte Export,
|
||||
* Production Lines, Normenrecherche.
|
||||
*
|
||||
* Run with:
|
||||
* npx playwright test e2e/specs/iace-features.spec.ts --config e2e/playwright-live.config.ts --reporter=list
|
||||
*/
|
||||
|
||||
const BASE = 'https://macmini:3007'
|
||||
|
||||
const PROJECTS = [
|
||||
{
|
||||
id: 'bb7d5b88-469d-401f-a0e3-ae5b867e4a1c',
|
||||
name: 'Kniehebelpresse HP-500',
|
||||
},
|
||||
{
|
||||
id: 'a4c4031e-75a5-461e-a575-159f1eabd6b3',
|
||||
name: 'EIGENBAU-Zelle (Cobot)',
|
||||
},
|
||||
{
|
||||
id: 'c43af8df-14e0-43ff-b26f-ab425f803e53',
|
||||
name: 'Gleichstrom-/Asynchronmotor',
|
||||
},
|
||||
{
|
||||
id: '3e0808b2-2eed-4e82-b35d-6dd6857bc379',
|
||||
name: 'Schwingarm-Rundtaktanlage',
|
||||
},
|
||||
] as const
|
||||
|
||||
const COBOT_PROJECT_ID = 'a4c4031e-75a5-461e-a575-159f1eabd6b3'
|
||||
const PRODUCTION_LINE_ID = 'c63b774e-22d4-4045-bb8d-646df626c42b'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function dismissCookieBanner(page: Page) {
|
||||
try {
|
||||
const acceptBtn = page.locator('button', { hasText: 'Nur notwendige Cookies' })
|
||||
if (await acceptBtn.isVisible({ timeout: 3000 })) {
|
||||
await acceptBtn.click({ force: true })
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
} catch {
|
||||
// Banner not present or already dismissed
|
||||
}
|
||||
}
|
||||
|
||||
async function goTo(page: Page, path: string) {
|
||||
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle', timeout: 30000 })
|
||||
await page.waitForTimeout(2000)
|
||||
await dismissCookieBanner(page)
|
||||
}
|
||||
|
||||
async function assertNoAppError(page: Page) {
|
||||
const body = await page.textContent('body')
|
||||
expect(body).not.toContain('Application error')
|
||||
expect(body).not.toContain('Unhandled Runtime Error')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-project new feature tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const project of PROJECTS) {
|
||||
// ------ Order tab ------
|
||||
test.describe(`Order: ${project.name}`, () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('order tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Auftrag', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('order — form fields visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Auftraggeber')
|
||||
expect(body).toContain('Firmenname')
|
||||
expect(body).toContain('Ansprechpartner')
|
||||
expect(body).toContain('E-Mail')
|
||||
expect(body).toContain('Telefon')
|
||||
})
|
||||
|
||||
test('order — status dropdown works', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
const statusSelect = page.locator('select')
|
||||
await expect(statusSelect.first()).toBeVisible({ timeout: 10000 })
|
||||
const options = statusSelect.first().locator('option')
|
||||
const count = await options.count()
|
||||
expect(count).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('order — Auftrag and Angebot sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
await expect(page.locator('text=Auftrag').first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Angebot').first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Notizen')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('order — scope options visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Risikobeurteilung')
|
||||
expect(body).toContain('CE-Kennzeichnung')
|
||||
})
|
||||
})
|
||||
|
||||
// ------ Interview (Grenzen & Verwendung) ------
|
||||
test.describe(`Interview: ${project.name}`, () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('interview tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Grenzen', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('interview — form sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
const body = await page.innerText('body')
|
||||
// Section titles contain the number prefix ("1. Allgemeine Produktbeschreibung")
|
||||
expect(body).toContain('Allgemeine Produktbeschreibung')
|
||||
expect(body).toContain('Bestimmungsgemasse Verwendung')
|
||||
expect(body).toContain('Vorhersehbare Fehlanwendung')
|
||||
expect(body).toContain('Grenzen der Maschine')
|
||||
expect(body).toContain('Schnittstellen')
|
||||
expect(body).toContain('Betroffene Personen')
|
||||
})
|
||||
|
||||
test('interview — pre-filled data visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
await page.waitForTimeout(2000)
|
||||
const body = await page.innerText('body')
|
||||
const hasProjectName = body.includes(project.name) || body.includes('Vorausgefuellt')
|
||||
expect(hasProjectName).toBeTruthy()
|
||||
})
|
||||
|
||||
test('interview — section collapse/expand works', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// Click on a collapsed section to expand it
|
||||
const sectionBtn = page.locator('button').filter({ hasText: 'Bestimmungsgemasse Verwendung' })
|
||||
await expect(sectionBtn).toBeVisible({ timeout: 10000 })
|
||||
await sectionBtn.click()
|
||||
await page.waitForTimeout(1000)
|
||||
// After expanding, "Verwendungszweck" should appear in the body
|
||||
const bodyAfter = await page.innerText('body')
|
||||
expect(bodyAfter).toContain('Verwendungszweck')
|
||||
})
|
||||
|
||||
test('interview — completion percentage visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// CompletionBadge shows "X% ausgefuellt" — use body text check
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toMatch(/\d+%\s*ausgefuellt/)
|
||||
})
|
||||
|
||||
test('interview — navigation buttons present', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Zurueck zur Uebersicht')
|
||||
expect(body).toContain('Weiter zu Komponenten')
|
||||
})
|
||||
})
|
||||
|
||||
// ------ Hazards: Risk Assessment ------
|
||||
test.describe(`Risk Assessment: ${project.name}`, () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('hazards — default view is Risikobewertung', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
await page.waitForTimeout(2000)
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Risikobewertungstabelle')
|
||||
})
|
||||
|
||||
test('hazards — S/E/P dropdowns visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
await page.waitForTimeout(3000)
|
||||
const selects = page.locator('select')
|
||||
await expect(selects.first()).toBeVisible({ timeout: 15000 })
|
||||
const selectCount = await selects.count()
|
||||
expect(selectCount).toBeGreaterThan(0)
|
||||
const firstValue = await selects.first().inputValue()
|
||||
const numValue = parseInt(firstValue, 10)
|
||||
expect(numValue).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('hazards — "Gefaehrdungen erkennen" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Gefaehrdungen erkennen' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ------ Mitigations: Batch actions ------
|
||||
test.describe(`Mitigations batch: ${project.name}`, () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('mitigations — 3 accordion sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await expect(page.locator('text=Stufe 1: Design')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Stufe 2: Schutz')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Stufe 3: Information')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('mitigations — checkbox selection works', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await page.waitForTimeout(2000)
|
||||
const checkboxes = page.locator('input[type="checkbox"]')
|
||||
const count = await checkboxes.count()
|
||||
if (count > 0) {
|
||||
await checkboxes.first().click()
|
||||
await page.waitForTimeout(500)
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('ausgewaehlt')
|
||||
}
|
||||
})
|
||||
|
||||
test('mitigations — batch buttons appear when items selected', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await page.waitForTimeout(2000)
|
||||
const checkboxes = page.locator('input[type="checkbox"]')
|
||||
const count = await checkboxes.count()
|
||||
if (count > 0) {
|
||||
await checkboxes.first().click()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Verifizieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Loeschen' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ------ Tech File: Export buttons ------
|
||||
test.describe(`Tech File Export: ${project.name}`, () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('tech-file — "PDF exportieren" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'PDF exportieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tech-file — "Excel exportieren" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Excel exportieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tech-file — progress or sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await page.waitForTimeout(2000)
|
||||
const body = await page.innerText('body')
|
||||
// Progress section or section list renders
|
||||
const hasContent = body.includes('Fortschritt') ||
|
||||
body.includes('Generieren') ||
|
||||
body.includes('Keine Abschnitte')
|
||||
expect(hasContent).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ------ Overview: Compliance Alerts & Normenrecherche ------
|
||||
test.describe(`Overview features: ${project.name}`, () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('overview — compliance alerts or quick actions visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
await assertNoAppError(page)
|
||||
const body = await page.innerText('body')
|
||||
const hasAlerts = body.includes('Compliance-Hinweise erkannt')
|
||||
const hasQuick = body.includes('Schnellzugriff')
|
||||
expect(hasAlerts || hasQuick).toBeTruthy()
|
||||
})
|
||||
|
||||
test('overview — regulation badges when alerts present', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Compliance-Hinweise erkannt')) {
|
||||
const hasBadge = body.includes('DSGVO') ||
|
||||
body.includes('AI Act') || body.includes('CRA')
|
||||
expect(hasBadge).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test('overview — normenrecherche section visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Normenrecherche')) {
|
||||
expect(body).toContain('relevante Normen')
|
||||
}
|
||||
})
|
||||
|
||||
test('overview — norm add input when norms visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
const addInput = page.locator('input[placeholder*="ISO 13857"]')
|
||||
if (await addInput.count() > 0) {
|
||||
await expect(addInput.first()).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compliance Alerts — Cobot project specific
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Compliance Alerts — Cobot', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('trigger count > 0', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Compliance-Hinweise erkannt')) {
|
||||
const match = body.match(/(\d+)\s*Compliance-Hinweise erkannt/)
|
||||
expect(match).not.toBeNull()
|
||||
if (match) {
|
||||
expect(parseInt(match[1], 10)).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('regulation badges visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Compliance-Hinweise erkannt')) {
|
||||
const hasDSGVO = body.includes('DSGVO')
|
||||
const hasAIAct = body.includes('AI Act')
|
||||
const hasCRA = body.includes('CRA')
|
||||
expect(hasDSGVO || hasAIAct || hasCRA).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production Lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Production Lines', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('lines list page loads', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Produktionslinien', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('"Neue Produktionslinie" button visible', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Neue Produktionslinie' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('"Fertigungsstrasse Halle 3" visible', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Fertigungsstrasse Halle 3')
|
||||
})
|
||||
|
||||
test('line dashboard loads with stations', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
await assertNoAppError(page)
|
||||
await page.waitForTimeout(3000)
|
||||
await expect(
|
||||
page.locator('text=Stationsuebersicht')
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('line dashboard — station cards rendered', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Stationsuebersicht')
|
||||
await expect(
|
||||
page.locator('text=Alle Produktionslinien')
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('line dashboard — back link', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
const backLink = page.locator('a', { hasText: 'Alle Produktionslinien' })
|
||||
await expect(backLink).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normenrecherche — Cobot project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Normenrecherche — Cobot', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('shows norm count', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Normenrecherche')) {
|
||||
expect(body).toContain('relevante Normen')
|
||||
// Extract and verify a substantial count
|
||||
const match = body.match(/(\d+)\s*relevante Normen/)
|
||||
if (match) {
|
||||
expect(parseInt(match[1], 10)).toBeGreaterThan(50)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('add norm input visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
const body = await page.innerText('body')
|
||||
// Check the "Weitere Norm ergaenzen" text and input field
|
||||
if (body.includes('Normenrecherche')) {
|
||||
expect(body).toContain('Weitere Norm ergaenzen')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,9 +4,7 @@ import { test, expect, Page } from '@playwright/test'
|
||||
* IACE (CE-Compliance) Module — Comprehensive E2E Tests
|
||||
*
|
||||
* Tests all 4 seeded projects across every tab:
|
||||
* Overview, Components, Hazards, Mitigations, Verification, Evidence, Tech-File, Monitoring,
|
||||
* Order, Interview (Grenzen & Verwendung), Compliance Alerts, Risk Assessment Table,
|
||||
* CE-Akte Export, Production Lines, Normenrecherche.
|
||||
* Overview, Components, Hazards, Mitigations, Verification, Evidence, Tech-File, Monitoring.
|
||||
*
|
||||
* Run with:
|
||||
* npx playwright test e2e/specs/iace-module.spec.ts --config e2e/playwright-live.config.ts --reporter=list
|
||||
@@ -45,12 +43,6 @@ const PROJECTS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
/** The Cobot project ID — used for compliance alerts checks. */
|
||||
const COBOT_PROJECT_ID = 'a4c4031e-75a5-461e-a575-159f1eabd6b3'
|
||||
|
||||
/** Seeded production line ID. */
|
||||
const PRODUCTION_LINE_ID = 'c63b774e-22d4-4045-bb8d-646df626c42b'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -119,7 +111,7 @@ test.describe('IACE Start Page', () => {
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2–9. Per-project tests (existing tabs + new features)
|
||||
// 2–9. Per-project tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const project of PROJECTS) {
|
||||
@@ -183,55 +175,6 @@ for (const project of PROJECTS) {
|
||||
await assertNoAppError(page)
|
||||
})
|
||||
|
||||
// ------ Overview: Compliance Alerts ------
|
||||
test('overview — compliance alerts section visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
// ComplianceAlerts renders only when triggers > 0.
|
||||
// Wait for content to settle, then check for either the alerts header or
|
||||
// the absence of an error (some projects may have 0 triggers).
|
||||
await page.waitForTimeout(3000)
|
||||
await assertNoAppError(page)
|
||||
const body = await page.innerText('body')
|
||||
// At least one of these should be present on the overview page:
|
||||
// The alerts section OR the norms section OR the quick actions section
|
||||
const hasAlerts = body.includes('Compliance-Hinweise erkannt')
|
||||
const hasNorms = body.includes('Normenrecherche')
|
||||
const hasQuick = body.includes('Schnellzugriff')
|
||||
expect(hasAlerts || hasNorms || hasQuick).toBeTruthy()
|
||||
})
|
||||
|
||||
test('overview — regulation badges visible when alerts present', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Compliance-Hinweise erkannt')) {
|
||||
// Regulation badges should be rendered (DSGVO, AI Act, CRA, NIS2, Data Act)
|
||||
const hasBadge = body.includes('DSGVO') || body.includes('AI Act') || body.includes('CRA')
|
||||
expect(hasBadge).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
// ------ Overview: Normenrecherche ------
|
||||
test('overview — normenrecherche section visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
// SuggestedNorms renders with the total count — verify it shows "relevante Normen"
|
||||
if (body.includes('Normenrecherche')) {
|
||||
expect(body).toContain('relevante Normen')
|
||||
}
|
||||
})
|
||||
|
||||
test('overview — norm add field visible when norms section open', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}`)
|
||||
await page.waitForTimeout(3000)
|
||||
// The SuggestedNorms section has a custom norm input with placeholder "z.B. ISO 13857:2019"
|
||||
const addInput = page.locator('input[placeholder*="ISO 13857"]')
|
||||
if (await addInput.count() > 0) {
|
||||
await expect(addInput.first()).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
|
||||
// ------ Components ------
|
||||
test('components tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/components`)
|
||||
@@ -253,106 +196,6 @@ for (const project of PROJECTS) {
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
// ------ Order ------
|
||||
test('order tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Auftrag', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('order — form fields visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
// The Auftraggeber section with Firmenname and Ansprechpartner
|
||||
await expect(page.locator('text=Auftraggeber')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('label:has-text("Firmenname")')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('label:has-text("Ansprechpartner")')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('label:has-text("E-Mail")')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('label:has-text("Telefon")')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('order — status dropdown works', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
// The Angebot section has a status select with options
|
||||
const statusSelect = page.locator('select')
|
||||
await expect(statusSelect.first()).toBeVisible({ timeout: 10000 })
|
||||
// Verify dropdown has the expected options
|
||||
const options = statusSelect.first().locator('option')
|
||||
const count = await options.count()
|
||||
expect(count).toBeGreaterThanOrEqual(3) // offen, angenommen, abgelehnt, storniert
|
||||
})
|
||||
|
||||
test('order — Auftrag and Angebot sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
await expect(page.locator('text=Auftrag').first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Angebot').first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Notizen')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('order — scope checkboxes visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/order`)
|
||||
// SCOPE_OPTIONS: Risikobeurteilung, Normenrecherche, Betriebsanleitung, CE-Kennzeichnung, Schulung
|
||||
await expect(page.locator('text=Risikobeurteilung').first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=CE-Kennzeichnung')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
// ------ Interview (Grenzen & Verwendung) ------
|
||||
test('interview tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Grenzen & Verwendung', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('interview — form sections visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// The 6 collapsible section headers
|
||||
await expect(page.locator('text=Allgemeine Produktbeschreibung')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Bestimmungsgemasse Verwendung')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Vorhersehbare Fehlanwendung')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Grenzen der Maschine')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Schnittstellen')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Betroffene Personen')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('interview — pre-filled data visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// First section is open by default and shows pre-filled machine name
|
||||
await page.waitForTimeout(2000)
|
||||
const body = await page.innerText('body')
|
||||
// The machine name from the project should appear somewhere in the form
|
||||
// (either in the input or in a "Vorausgefuellt" help text)
|
||||
const hasProjectName = body.includes(project.name) || body.includes('Vorausgefuellt')
|
||||
expect(hasProjectName).toBeTruthy()
|
||||
})
|
||||
|
||||
test('interview — section collapse/expand works', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// "Bestimmungsgemasse Verwendung" section is collapsed by default
|
||||
// Click to expand it
|
||||
const sectionBtn = page.locator('button', { hasText: 'Bestimmungsgemasse Verwendung' })
|
||||
await expect(sectionBtn).toBeVisible({ timeout: 10000 })
|
||||
await sectionBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
// After expanding, we should see "Verwendungszweck" label inside
|
||||
await expect(page.locator('text=Verwendungszweck')).toBeVisible({ timeout: 10000 })
|
||||
// Click again to collapse
|
||||
await sectionBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
// Content should no longer be visible
|
||||
await expect(page.locator('label:has-text("Verwendungszweck")')).not.toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('interview — completion badge visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
// CompletionBadge shows "X% ausgefuellt"
|
||||
await expect(page.locator('text=ausgefuellt')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('interview — navigation buttons present', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/interview`)
|
||||
await expect(page.locator('text=Zurueck zur Uebersicht')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Weiter zu Komponenten')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
// ------ Hazards ------
|
||||
test('hazards tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
@@ -370,41 +213,6 @@ for (const project of PROJECTS) {
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('hazards — default view is Risikobewertung', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
// The "Risikobewertung" button should be the active one (has bg-purple-600)
|
||||
const riskBtn = page.locator('button', { hasText: 'Risikobewertung' })
|
||||
await expect(riskBtn).toBeVisible({ timeout: 10000 })
|
||||
// Default state is 'risk', so the RiskAssessmentTable should be rendered
|
||||
// Wait for it to load
|
||||
await page.waitForTimeout(2000)
|
||||
// Check that the risk assessment table header is visible
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Risikobewertungstabelle')
|
||||
})
|
||||
|
||||
test('hazards — S/E/P dropdowns visible with values > 1', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
// Default view is risk assessment — wait for table
|
||||
await page.waitForTimeout(3000)
|
||||
// RiskAssessmentTable renders <select> elements for S/E/P
|
||||
const selects = page.locator('select')
|
||||
await expect(selects.first()).toBeVisible({ timeout: 15000 })
|
||||
const selectCount = await selects.count()
|
||||
expect(selectCount).toBeGreaterThan(0)
|
||||
// Check that at least one select has a value > 1
|
||||
const firstValue = await selects.first().inputValue()
|
||||
const numValue = parseInt(firstValue, 10)
|
||||
expect(numValue).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('hazards — "Gefaehrdungen erkennen" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Gefaehrdungen erkennen' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('hazards — switch to risk assessment view', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/hazards`)
|
||||
// Click the "Risikobewertung" toggle
|
||||
@@ -447,7 +255,7 @@ for (const project of PROJECTS) {
|
||||
await expect(page.locator('h1')).toContainText('Massnahmen', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('mitigations — 3 accordion sections visible', async ({ page }) => {
|
||||
test('mitigations — 3-column layout visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await expect(page.locator('text=Stufe 1: Design')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('text=Stufe 2: Schutz')).toBeVisible({ timeout: 10000 })
|
||||
@@ -468,44 +276,17 @@ for (const project of PROJECTS) {
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('mitigations — checkbox selection works', async ({ page }) => {
|
||||
test('mitigations — add buttons per column', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await page.waitForTimeout(2000)
|
||||
// Find the first checkbox in the mitigations table rows
|
||||
const checkboxes = page.locator('input[type="checkbox"]')
|
||||
const count = await checkboxes.count()
|
||||
if (count > 0) {
|
||||
// Click the first non-header checkbox to select an item
|
||||
await checkboxes.first().click()
|
||||
await page.waitForTimeout(500)
|
||||
// After selecting, batch action buttons should appear: "ausgewaehlt" text
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('ausgewaehlt')
|
||||
}
|
||||
const addButtons = page.locator('button', { hasText: '+ Hinzufuegen' })
|
||||
const count = await addButtons.count()
|
||||
expect(count).toBe(3)
|
||||
})
|
||||
|
||||
test('mitigations — batch buttons appear when items selected', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await page.waitForTimeout(2000)
|
||||
const checkboxes = page.locator('input[type="checkbox"]')
|
||||
const count = await checkboxes.count()
|
||||
if (count > 0) {
|
||||
await checkboxes.first().click()
|
||||
await page.waitForTimeout(500)
|
||||
// Batch action buttons: Verifizieren and Loeschen
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Verifizieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Loeschen' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
|
||||
test('mitigations — add buttons visible', async ({ page }) => {
|
||||
test('mitigations — "Massnahme hinzufuegen" button', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/mitigations`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: '+ Hinzufuegen' })
|
||||
page.locator('button', { hasText: 'Massnahme hinzufuegen' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
@@ -537,31 +318,6 @@ for (const project of PROJECTS) {
|
||||
await expect(page.locator('h1')).toContainText('CE-Akte', { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tech-file — "PDF exportieren" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'PDF exportieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tech-file — "Excel exportieren" button visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Excel exportieren' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tech-file — progress bar and section list visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/tech-file`)
|
||||
await page.waitForTimeout(2000)
|
||||
const body = await page.innerText('body')
|
||||
// Progress section always renders
|
||||
expect(body).toContain('Fortschritt')
|
||||
// Either sections are listed or the empty state shows
|
||||
const hasSections = body.includes('Generieren') || body.includes('Keine Abschnitte vorhanden')
|
||||
expect(hasSections).toBeTruthy()
|
||||
})
|
||||
|
||||
// ------ Monitoring ------
|
||||
test('monitoring tab loads', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${project.id}/monitoring`)
|
||||
@@ -570,131 +326,3 @@ for (const project of PROJECTS) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Compliance Alerts — Cobot project specific
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Compliance Alerts — Cobot Project', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('compliance alerts show trigger count > 0', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
// ComplianceAlerts shows "X Compliance-Hinweise erkannt"
|
||||
const alertsHeader = page.locator('text=Compliance-Hinweise erkannt')
|
||||
if (await alertsHeader.isVisible({ timeout: 10000 })) {
|
||||
const headerText = await alertsHeader.innerText()
|
||||
// Extract the count from "X Compliance-Hinweise erkannt"
|
||||
const match = headerText.match(/(\d+)/)
|
||||
expect(match).not.toBeNull()
|
||||
if (match) {
|
||||
expect(parseInt(match[1], 10)).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('compliance alerts — regulation badges visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
const body = await page.innerText('body')
|
||||
if (body.includes('Compliance-Hinweise erkannt')) {
|
||||
// At least some of: DSGVO, AI Act, CRA, NIS2, Data Act
|
||||
const hasDSGVO = body.includes('DSGVO')
|
||||
const hasAIAct = body.includes('AI Act')
|
||||
const hasCRA = body.includes('CRA')
|
||||
expect(hasDSGVO || hasAIAct || hasCRA).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. Production Lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Production Lines', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('lines list page loads', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await assertNoAppError(page)
|
||||
await expect(page.locator('h1')).toContainText('Produktionslinien', { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('lines — "Neue Produktionslinie" button visible', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await expect(
|
||||
page.locator('button', { hasText: 'Neue Produktionslinie' })
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('lines — "Fertigungsstrasse Halle 3" visible', async ({ page }) => {
|
||||
await goTo(page, '/sdk/iace/lines')
|
||||
await page.waitForTimeout(3000)
|
||||
const body = await page.innerText('body')
|
||||
// The seeded line should appear in the list
|
||||
expect(body).toContain('Fertigungsstrasse Halle 3')
|
||||
})
|
||||
|
||||
test('line dashboard loads with stations', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
await assertNoAppError(page)
|
||||
await page.waitForTimeout(3000)
|
||||
// The dashboard should show "Stationsuebersicht" heading
|
||||
await expect(
|
||||
page.locator('text=Stationsuebersicht')
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('line dashboard — station cards rendered', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
await page.waitForTimeout(3000)
|
||||
// The dashboard renders StationCard components with project machine names
|
||||
// At least one station should be present
|
||||
const body = await page.innerText('body')
|
||||
expect(body).toContain('Stationsuebersicht')
|
||||
// Check that we have "Alle Produktionslinien" back link
|
||||
await expect(
|
||||
page.locator('text=Alle Produktionslinien')
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('line dashboard — back link to lines list', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/lines/${PRODUCTION_LINE_ID}`)
|
||||
const backLink = page.locator('a', { hasText: 'Alle Produktionslinien' })
|
||||
await expect(backLink).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Normenrecherche — large norm count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Normenrecherche — Cobot Project', () => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('normenrecherche shows large norm count', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
const body = await page.innerText('body')
|
||||
// SuggestedNorms shows "Normenrecherche — X relevante Normen"
|
||||
if (body.includes('Normenrecherche')) {
|
||||
const match = body.match(/Normenrecherche\s*[—-]\s*(\d+)\s*relevante Normen/)
|
||||
if (match) {
|
||||
const normCount = parseInt(match[1], 10)
|
||||
// Should be a substantial number (not just 215 from the old fallback)
|
||||
expect(normCount).toBeGreaterThan(100)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('normenrecherche — add norm input visible', async ({ page }) => {
|
||||
await goTo(page, `/sdk/iace/${COBOT_PROJECT_ID}`)
|
||||
await page.waitForTimeout(4000)
|
||||
// The "Weitere Norm ergaenzen" section has an input with ISO placeholder
|
||||
const addInput = page.locator('input[placeholder*="ISO 13857"]')
|
||||
if (await addInput.count() > 0) {
|
||||
await expect(addInput.first()).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
checks — Cookie-banner compliance checkers (L1/L2 hierarchy).
|
||||
|
||||
Provides a structured checklist for verifying cookie banner compliance
|
||||
against EDPB guidelines, CNIL enforcement, EuGH rulings, and national law.
|
||||
|
||||
Two check levels:
|
||||
L1 — "Does the banner meet this fundamental requirement?"
|
||||
L2 — "Is the specific sub-requirement fulfilled correctly?"
|
||||
"""
|
||||
|
||||
from .banner_checks import BANNER_CHECKLIST
|
||||
|
||||
__all__ = [
|
||||
"BANNER_CHECKLIST",
|
||||
]
|
||||
@@ -0,0 +1,708 @@
|
||||
"""
|
||||
Cookie-Banner Compliance Checks — L1/L2 Hierarchy.
|
||||
|
||||
6 L1 checks (fundamental requirements) with 30 L2 detail checks.
|
||||
Each check_key maps to an existing check in banner_text_checker.py
|
||||
or banner_advanced_checks.py.
|
||||
|
||||
Legal references: EDPB Guidelines 3/2022 (Deceptive Design Patterns),
|
||||
EDPB Guidelines 05/2020 (Consent), CNIL Leitlinien, EuGH C-673/17
|
||||
(Planet49), §25 TDDDG, Art. 5(3) ePrivacy-RL, Art. 7/12/13 DSGVO.
|
||||
"""
|
||||
|
||||
BANNER_CHECKLIST = [
|
||||
# =====================================================================
|
||||
# L1-1: Banner vorhanden
|
||||
# =====================================================================
|
||||
{
|
||||
"id": "banner_present",
|
||||
"label": "Cookie-Banner vorhanden und funktional",
|
||||
"level": 1,
|
||||
"parent": None,
|
||||
"check_key": "banner_detected",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"Wer nicht-essentielle Cookies oder Tracking einsetzt, braucht eine "
|
||||
"Einwilligungsabfrage BEVOR der Zugriff auf das Endgeraet erfolgt "
|
||||
"(ss25 Abs. 1 TDDDG, Art. 5(3) ePrivacy-RL). Ohne Banner ist jedes "
|
||||
"gesetzte Tracking-Cookie rechtswidrig. Ausnahme: Rein technisch "
|
||||
"notwendige Cookies (Session-ID, Warenkorb, Load-Balancer) benoetigen "
|
||||
"kein Banner (ss25 Abs. 2 TDDDG). Haeufiger Fehler: Website setzt "
|
||||
"Google Analytics, hat aber kein Banner — das ist ein Verstoss ab dem "
|
||||
"ersten Seitenaufruf."
|
||||
),
|
||||
},
|
||||
# ── L2 unter banner_present ──────────────────────────────────────
|
||||
{
|
||||
"id": "banner_language_match",
|
||||
"label": "Banner-Sprache entspricht Seitensprache",
|
||||
"level": 2,
|
||||
"parent": "banner_present",
|
||||
"check_key": "banner_language_mismatch",
|
||||
"severity": "MEDIUM",
|
||||
"hint": (
|
||||
"Art. 12(1) DSGVO: Informationen muessen in 'klarer und einfacher "
|
||||
"Sprache' bereitgestellt werden. Ein englisches Banner auf einer "
|
||||
"deutschen Seite (oder umgekehrt) erfuellt dieses Kriterium nicht, "
|
||||
"weil Nutzer die Tragweite ihrer Einwilligung nicht verstehen koennen "
|
||||
"(ErwGr. 39, 42 DSGVO). Haeufiger Fehler: CMP-Standardsprache ist "
|
||||
"Englisch und wurde nie auf die Seitensprache umgestellt. Pruefung: "
|
||||
"<html lang='de'> vs. Banner-Text-Sprache vergleichen."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "banner_provider_identifiable",
|
||||
"label": "CMP-Anbieter identifizierbar",
|
||||
"level": 2,
|
||||
"parent": "banner_present",
|
||||
"check_key": "banner_provider_named",
|
||||
"severity": "LOW",
|
||||
"hint": (
|
||||
"Obwohl gesetzlich nicht explizit gefordert, erleichtert die "
|
||||
"Identifizierbarkeit des CMP-Anbieters (Cookiebot, OneTrust, "
|
||||
"Usercentrics etc.) die Pruefung, ob der Banner korrekt "
|
||||
"konfiguriert ist. Viele CMP-Anbieter sind Auftragsverarbeiter "
|
||||
"nach Art. 28 DSGVO — in diesem Fall muss ein AV-Vertrag "
|
||||
"vorliegen. Haeufiger Fehler: CMP sendet Consent-Signale an "
|
||||
"eigene Server ohne AV-Vertrag."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "banner_visible_on_load",
|
||||
"label": "Banner erscheint beim ersten Seitenaufruf",
|
||||
"level": 2,
|
||||
"parent": "banner_present",
|
||||
"check_key": "banner_detected",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"ss25 Abs. 1 TDDDG: Die Einwilligung muss VOR dem Zugriff "
|
||||
"auf das Endgeraet eingeholt werden. Wenn der Banner erst nach "
|
||||
"Scrollen, nach einer Verzoegerung oder gar nicht erscheint, "
|
||||
"aber gleichzeitig Tracking-Scripts geladen werden, liegt ein "
|
||||
"Verstoss vor. Haeufiger Fehler: CMP ist installiert, aber "
|
||||
"der Banner wird wegen eines JavaScript-Fehlers oder einer "
|
||||
"fehlerhaften Geolocation-Einstellung (z.B. 'nur fuer EU-"
|
||||
"Nutzer') nicht angezeigt. Auch Caching-Probleme koennen "
|
||||
"dazu fuehren, dass der Banner bei wiederholtem Besuch "
|
||||
"nicht geladen wird, obwohl kein Consent vorliegt."
|
||||
),
|
||||
},
|
||||
|
||||
# =====================================================================
|
||||
# L1-2: Wahlmoeglichkeit (Akzeptieren + Ablehnen)
|
||||
# =====================================================================
|
||||
{
|
||||
"id": "banner_choices",
|
||||
"label": "Wahlmoeglichkeit (Akzeptieren + Ablehnen)",
|
||||
"level": 1,
|
||||
"parent": None,
|
||||
"check_key": "reject_button_visible",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"EDPB Guidelines 05/2020, Rn. 41-42: Eine gueltige Einwilligung "
|
||||
"erfordert eine echte Wahlmoeglichkeit. Der Nutzer muss Cookies "
|
||||
"ebenso einfach ablehnen koennen wie annehmen. Die CNIL hat am "
|
||||
"31.12.2021 gegen Google (150 Mio. EUR) und Facebook (60 Mio. EUR) "
|
||||
"Bussgelder verhaengt, u.a. weil Ablehnung nicht gleichwertig "
|
||||
"moeglich war (CNIL SAN-2021-023/024). Ein Banner ohne Ablehnen-"
|
||||
"Option ist keine gueltige Einwilligungsabfrage."
|
||||
),
|
||||
},
|
||||
# ── L2 unter banner_choices ──────────────────────────────────────
|
||||
{
|
||||
"id": "reject_visible",
|
||||
"label": "Ablehnen-Button auf erster Ebene sichtbar",
|
||||
"level": 2,
|
||||
"parent": "banner_choices",
|
||||
"check_key": "reject_button_visible",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"ss25 TDDDG i.V.m. EDPB Guidelines 05/2020, Rn. 86: Der "
|
||||
"Ablehnen-Button muss auf der ersten Ebene des Banners sichtbar "
|
||||
"sein — nicht hinter 'Einstellungen' oder 'Mehr Informationen' "
|
||||
"versteckt. Die franzoesische CNIL hat dies in ihrem Google-"
|
||||
"Entscheid (SAN-2021-023) explizit geruegt: 'refuser devait etre "
|
||||
"aussi facile qu'accepter'. Haeufiger Fehler: Banner zeigt nur "
|
||||
"'Akzeptieren' + 'Einstellungen', Ablehnung erst im Untermenue."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "reject_same_clicks",
|
||||
"label": "Gleiche Klickanzahl fuer Akzeptieren und Ablehnen",
|
||||
"level": 2,
|
||||
"parent": "banner_choices",
|
||||
"check_key": "click_count_asymmetry",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"CNIL SAN-2021-023 (Google, 150 Mio. EUR): 'Le refus necessitait "
|
||||
"plusieurs clics alors que l'acceptation pouvait se faire en un "
|
||||
"seul clic.' Ablehnung und Zustimmung muessen mit identischer "
|
||||
"Klickanzahl erreichbar sein. 1 Klick Accept vs. 2+ Klicks Reject "
|
||||
"ist ein Verstoss. Auch die oesterreichische DSB hat dies in "
|
||||
"Bescheid D155.520 bestaetigt. Haeufiger Fehler: Accept = 1 Klick, "
|
||||
"Reject = Einstellungen oeffnen + Toggle deaktivieren + Speichern "
|
||||
"= 3 Klicks."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "reject_same_size",
|
||||
"label": "Gleiche Button-Groesse (kein Dark Pattern)",
|
||||
"level": 2,
|
||||
"parent": "banner_choices",
|
||||
"check_key": "dark_pattern_button_size",
|
||||
"severity": "MEDIUM",
|
||||
"hint": (
|
||||
"EDPB Guidelines 3/2022 (Deceptive Design Patterns), Rn. 62: "
|
||||
"Akzeptieren und Ablehnen muessen 'gleichwertig praesentiert' "
|
||||
"werden. Konkret: Gleiche Schriftgroesse, Buttongroesse und "
|
||||
"Farbprominenz. Ein ueberproportional grosser Accept-Button "
|
||||
"(area ratio > 2.5x) gegenueber einem kleinen Reject-Link ist "
|
||||
"ein klassisches Dark Pattern. Die CNIL hat Google und Meta "
|
||||
"u.a. wegen solcher Klick-Asymmetrie bestraft. Pruefung: "
|
||||
"Button-Flaeche und Schriftgroesse beider Optionen vergleichen."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "reject_same_prominence",
|
||||
"label": "Gleiche Farbgebung/Kontrast (kein Contrast-Trick)",
|
||||
"level": 2,
|
||||
"parent": "banner_choices",
|
||||
"check_key": "color_contrast_dark_pattern",
|
||||
"severity": "MEDIUM",
|
||||
"hint": (
|
||||
"EDPB Guidelines 3/2022, Rn. 63-65 (Interface Interference): "
|
||||
"Wenn der Accept-Button farblich hervorgehoben ist und der "
|
||||
"Reject-Button die gleiche Farbe wie der Hintergrund hat "
|
||||
"(transparent oder kaum sichtbar), liegt ein Deceptive Design "
|
||||
"Pattern vom Typ 'Hidden in Plain Sight' vor. Beispiel: "
|
||||
"Gruener Accept-Button vs. grauer Text-Link 'Ablehnen' der "
|
||||
"im Hintergrund verschwindet. Pruefung: background-color des "
|
||||
"Reject-Buttons darf nicht identisch mit Banner-Hintergrund sein."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "reject_no_scroll",
|
||||
"label": "Ablehnen ohne Scrollen erreichbar",
|
||||
"level": 2,
|
||||
"parent": "banner_choices",
|
||||
"check_key": "nudging_reject_hidden",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"EDPB Guidelines 3/2022, Rn. 48-50 (Hindering): Der Ablehnen-"
|
||||
"Button darf nicht ausserhalb des sichtbaren Banner-Bereichs "
|
||||
"platziert werden, sodass Nutzer scrollen muessen. Dies ist ein "
|
||||
"'Longer than necessary'-Pattern. Der BGH hat in seiner "
|
||||
"Planet49-Nachfolgeentscheidung (I ZR 7/16) klargestellt, dass "
|
||||
"die Ablehnung 'ohne unzumutbaren Aufwand' moeglich sein muss. "
|
||||
"Haeufiger Fehler: Banner mit langem Text, Reject-Button erst "
|
||||
"am Ende sichtbar."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "reject_no_nudging",
|
||||
"label": "Keine manipulative Sprache (Stirring/Nudging)",
|
||||
"level": 2,
|
||||
"parent": "banner_choices",
|
||||
"check_key": "stirring_emotional_language",
|
||||
"severity": "LOW",
|
||||
"hint": (
|
||||
"EDPB Guidelines 3/2022, Rn. 55-59 (Emotional Steering): "
|
||||
"Formulierungen wie 'eingeschraenkte Funktionen', 'bestmoegliches "
|
||||
"Erlebnis' oder 'Website funktioniert moeglicherweise nicht "
|
||||
"richtig' erzeugen emotionalen Druck und beeintraechtigen die "
|
||||
"Freiwilligkeit der Einwilligung (Art. 7(4) DSGVO). Auch "
|
||||
"sogenannte 'Confirmshaming'-Texte auf dem Ablehnen-Button "
|
||||
"(z.B. 'Nein, ich moechte kein gutes Erlebnis') sind unzulaessig. "
|
||||
"Korrekt: Neutral formulieren, z.B. 'Alle akzeptieren' / "
|
||||
"'Nur Notwendige'."
|
||||
),
|
||||
},
|
||||
|
||||
# =====================================================================
|
||||
# L1-3: Rechtliche Links (Impressum + DSE)
|
||||
# =====================================================================
|
||||
{
|
||||
"id": "banner_legal_links",
|
||||
"label": "Rechtliche Links (Impressum + DSE erreichbar)",
|
||||
"level": 1,
|
||||
"parent": None,
|
||||
"check_key": "impressum_link",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"ss5 TMG / ss5 DDG (ab 2025): Das Impressum muss 'leicht erkennbar, "
|
||||
"unmittelbar erreichbar und staendig verfuegbar' sein — auch wenn "
|
||||
"ein Cookie-Banner die Seite ueberlagert. LG Rostock (Az. 3 O "
|
||||
"22/19): Ein ueberlagernder Banner, der das Impressum verdeckt, "
|
||||
"ohne selbst einen Link zu enthalten, verstoesst gegen die "
|
||||
"Impressumspflicht. Gleichzeitig verlangt Art. 13 DSGVO, dass "
|
||||
"die Datenschutzerklaerung VOR der Einwilligung einsehbar ist "
|
||||
"(informierte Einwilligung, ErwGr. 42)."
|
||||
),
|
||||
},
|
||||
# ── L2 unter banner_legal_links ──────────────────────────────────
|
||||
{
|
||||
"id": "impressum_accessible",
|
||||
"label": "Impressum trotz Banner-Overlay erreichbar",
|
||||
"level": 2,
|
||||
"parent": "banner_legal_links",
|
||||
"check_key": "impressum_link",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"ss5 TMG / ss5 DDG, LG Rostock Az. 3 O 22/19: Wenn ein modaler "
|
||||
"Cookie-Banner die Seite ueberlagert, MUSS ein Impressum-Link "
|
||||
"entweder im Banner selbst oder hinter dem Banner sichtbar sein. "
|
||||
"Ohne erreichbares Impressum droht eine Abmahnung nach UWG "
|
||||
"(ss3a UWG i.V.m. ss5 TMG). Haeufiger Fehler: Banner ueberlagert "
|
||||
"den gesamten Viewport, Impressum-Link im Footer ist nicht "
|
||||
"anklickbar. Loesung: Impressum-Link direkt in den Banner "
|
||||
"integrieren."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "dse_link_present",
|
||||
"label": "Link zur Datenschutzerklaerung im Banner",
|
||||
"level": 2,
|
||||
"parent": "banner_legal_links",
|
||||
"check_key": "dse_link",
|
||||
"severity": "MEDIUM",
|
||||
"hint": (
|
||||
"Art. 13 DSGVO i.V.m. ErwGr. 42: Eine 'informierte Einwilligung' "
|
||||
"setzt voraus, dass der Nutzer die Datenschutzinformationen VOR "
|
||||
"seiner Entscheidung einsehen kann. Ohne DSE-Link im Banner fehlt "
|
||||
"die Informationsgrundlage — die Einwilligung kann unwirksam sein. "
|
||||
"Die belgische DPA hat im TCF-Entscheid (21-02-2022) festgestellt, "
|
||||
"dass fehlender Zugang zur DSE vor Consent die Einwilligung "
|
||||
"nichtig macht. Haeufiger Fehler: DSE-Link nur im Footer, der "
|
||||
"vom Banner verdeckt wird."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "dse_link_own_domain",
|
||||
"label": "DSE-Link zeigt auf eigene Domain (nicht Drittanbieter)",
|
||||
"level": 2,
|
||||
"parent": "banner_legal_links",
|
||||
"check_key": "third_party_dse_link",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"Art. 13/14 DSGVO: Jeder Verantwortliche muss eine EIGENE "
|
||||
"Datenschutzerklaerung bereitstellen. Ein Verweis auf die DSE "
|
||||
"des CMP-Anbieters, des Hosters oder einer Muttergesellschaft "
|
||||
"genuegt nicht, wenn der Website-Betreiber selbst Verantwortlicher "
|
||||
"ist. Bei gemeinsamer Verantwortlichkeit (Art. 26 DSGVO) muss die "
|
||||
"Vereinbarung offengelegt werden. Haeufiger Fehler: Shopify-Shop "
|
||||
"verlinkt auf Shopify-DSE statt eigene."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "dse_readable_before_consent",
|
||||
"label": "DSE vor Einwilligung einsehbar (nicht hinter Consent-Gate)",
|
||||
"level": 2,
|
||||
"parent": "banner_legal_links",
|
||||
"check_key": "dse_link",
|
||||
"severity": "MEDIUM",
|
||||
"hint": (
|
||||
"ErwGr. 42 DSGVO: 'Damit die Einwilligung in Kenntnis der "
|
||||
"Sachlage erteilt wird, sollte die betroffene Person mindestens "
|
||||
"wissen, wer der Verantwortliche ist und fuer welche Zwecke ihre "
|
||||
"personenbezogenen Daten verarbeitet werden sollen.' Wenn die DSE-"
|
||||
"Seite selbst erst nach Cookie-Akzeptanz ladbar ist (z.B. weil "
|
||||
"sie hinter einem Cookie-Wall liegt), ist die Einwilligung nicht "
|
||||
"informiert und damit unwirksam. Pruefung: DSE-Link im Banner "
|
||||
"muss ohne vorherige Consent-Entscheidung oeffenbar sein."
|
||||
),
|
||||
},
|
||||
|
||||
# =====================================================================
|
||||
# L1-4: Gueltige Einwilligung (keine Planet49-Verstoesse)
|
||||
# =====================================================================
|
||||
{
|
||||
"id": "banner_consent_valid",
|
||||
"label": "Gueltige Einwilligung (DSGVO-konform)",
|
||||
"level": 1,
|
||||
"parent": None,
|
||||
"check_key": "pre_ticked_checkboxes",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"EuGH C-673/17 (Planet49, 01.10.2019): Eine gueltige Einwilligung "
|
||||
"erfordert eine 'aktive Handlung' — vorausgefuellte Checkboxen, "
|
||||
"Weitersurfen oder Inaktivitaet genuegen nicht. Art. 4(11) DSGVO "
|
||||
"definiert Einwilligung als 'freiwillig fuer den bestimmten Fall, "
|
||||
"in informierter Weise und unmisstdeutig abgegebene "
|
||||
"Willensbekundung'. Jeder dieser vier Bestandteile muss erfuellt "
|
||||
"sein — fehlt einer, ist die gesamte Einwilligung unwirksam."
|
||||
),
|
||||
},
|
||||
# ── L2 unter banner_consent_valid ────────────────────────────────
|
||||
{
|
||||
"id": "no_pre_ticked",
|
||||
"label": "Keine vorausgewaehlten Checkboxen (Planet49)",
|
||||
"level": 2,
|
||||
"parent": "banner_consent_valid",
|
||||
"check_key": "pre_ticked_checkboxes",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"EuGH C-673/17 (Planet49), Rn. 52-58: 'Ein voreingestelltes "
|
||||
"Ankreuzkaestchen genuegt nicht.' Der BGH hat dies in der "
|
||||
"Folgeentscheidung (I ZR 7/16) bestaetigt. Konkret: Checkboxen "
|
||||
"fuer Marketing, Analytics oder Drittanbieter-Cookies duerfen "
|
||||
"NICHT vorangehakt sein. Nur technisch notwendige Kategorien "
|
||||
"(die keiner Einwilligung beduerfen) duerfen vorausgewaehlt und "
|
||||
"ausgegraut sein. Haeufiger Fehler: CMP setzt 'Funktionale "
|
||||
"Cookies' vorab auf aktiv — wenn diese Kategorie Drittanbieter "
|
||||
"enthaelt, ist das ein Planet49-Verstoss."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_wrong_dse_wording",
|
||||
"label": "Keine falsche Formulierung ('Zustimmung zur DSE')",
|
||||
"level": 2,
|
||||
"parent": "banner_consent_valid",
|
||||
"check_key": "wrong_dse_consent",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"Art. 13 DSGVO: Die Datenschutzerklaerung ist eine "
|
||||
"Informationspflicht des Verantwortlichen — der Nutzer kann sie "
|
||||
"nur 'zur Kenntnis nehmen', nicht 'zustimmen' oder 'akzeptieren'. "
|
||||
"Formulierungen wie 'Ich stimme der Datenschutzerklaerung zu' "
|
||||
"oder 'Ich akzeptiere die Privacy Policy' sind rechtlich falsch "
|
||||
"und koennen die Einwilligung anfechtbar machen. Korrekt: 'Ich "
|
||||
"habe die Datenschutzinformationen zur Kenntnis genommen.' "
|
||||
"Haeufiger Fehler: CMP-Standardtexte verwenden 'agree to privacy "
|
||||
"policy' und werden nicht angepasst."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_modal_dismiss",
|
||||
"label": "Klick ausserhalb des Banners ist keine Einwilligung",
|
||||
"level": 2,
|
||||
"parent": "banner_consent_valid",
|
||||
"check_key": "non_modal_dismiss",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"EuGH C-673/17 (Planet49), Rn. 49-50: 'Stillschweigen, bereits "
|
||||
"angekreuzte Kaestchen oder Untaetigkeit der betroffenen Person "
|
||||
"stellen keine Einwilligung dar.' EDPB Guidelines 05/2020, "
|
||||
"Rn. 77: Inaktivitaet, Weitersurfen oder Wegklicken eines "
|
||||
"Dialogs darf NICHT als Einwilligung gewertet werden. Wenn ein "
|
||||
"nicht-modaler Dialog bei Klick auf den Hintergrund verschwindet "
|
||||
"und dabei Consent gesetzt wird, ist diese Einwilligung nichtig. "
|
||||
"Loesung: Dialog muss modal sein (aria-modal='true'), nur "
|
||||
"explizite Button-Klicks zaehlen."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_coupling",
|
||||
"label": "Kein Koppelungsverbot-Verstoss (Art. 7(4))",
|
||||
"level": 2,
|
||||
"parent": "banner_consent_valid",
|
||||
"check_key": "registration_consent_coupling",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"Art. 7(4) DSGVO, ErwGr. 43: Wenn die Erfuellung eines Vertrags "
|
||||
"(z.B. Registrierung, Kauf) von einer Einwilligung abhaengt, die "
|
||||
"fuer die Vertragserfuellung nicht erforderlich ist, besteht ein "
|
||||
"Koppelungsverbot-Verstoss. Beispiel: Login-Button erteilt "
|
||||
"gleichzeitig Marketing-Einwilligung ohne separate Checkbox. "
|
||||
"Die oesterreichische DSB hat in Bescheid 2021-0.586.257 "
|
||||
"klargestellt, dass 'bundling' verschiedener Zwecke in einer "
|
||||
"einzigen Einwilligung die Freiwilligkeit ausschliesst. "
|
||||
"Pruefung: Login-/Registrierungsformulare auf versteckte "
|
||||
"Consent-Kopplung pruefen."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_emotional_language",
|
||||
"label": "Keine manipulative/emotionale Sprache (Stirring)",
|
||||
"level": 2,
|
||||
"parent": "banner_consent_valid",
|
||||
"check_key": "stirring_emotional_language",
|
||||
"severity": "LOW",
|
||||
"hint": (
|
||||
"EDPB Guidelines 3/2022, Rn. 55-59 (Emotional Steering): "
|
||||
"Formulierungen die Angst, Schuldgefuehle oder Verlustangst "
|
||||
"erzeugen, beeintraechtigen die Freiwilligkeit nach Art. 7(4) "
|
||||
"DSGVO. Typische Muster: 'Ohne Cookies koennen wir Ihnen kein "
|
||||
"optimales Erlebnis bieten', 'Einige Funktionen stehen nicht zur "
|
||||
"Verfuegung'. Die spanische AEPD hat in Entscheid PS/00543/2021 "
|
||||
"emotionale Sprache in Cookie-Bannern als Verstoss gewertet. "
|
||||
"Korrekt: Sachliche Beschreibung der Zwecke ohne Wertung."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_false_necessity",
|
||||
"label": "Keine falsche Notwendigkeits-Behauptung (Dark Pattern Language)",
|
||||
"level": 2,
|
||||
"parent": "banner_consent_valid",
|
||||
"check_key": "dark_pattern_language",
|
||||
"severity": "MEDIUM",
|
||||
"hint": (
|
||||
"EDPB Guidelines 05/2020, Rn. 70, Art. 7(4) DSGVO: "
|
||||
"Formulierungen wie 'Cookies muessen akzeptiert werden', "
|
||||
"'Cookies sind erforderlich' oder 'must be downloaded' fuer "
|
||||
"nicht-essentielle Cookies suggerieren eine technische "
|
||||
"Notwendigkeit, die nicht besteht. Dies untermieniert die "
|
||||
"Freiwilligkeit der Einwilligung. Abzugrenzen: Die Aussage "
|
||||
"'Technisch notwendige Cookies sind erforderlich fuer den "
|
||||
"Betrieb' ist zulaessig — aber nur wenn sie sich klar auf die "
|
||||
"essentielle Kategorie bezieht. Haeufiger Fehler: Pauschale "
|
||||
"Formulierung 'Alle Cookies sind fuer den Betrieb notwendig' "
|
||||
"obwohl Analytics- und Marketing-Cookies enthalten sind."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "consent_revocable",
|
||||
"label": "Einstellungen erneut zugaenglich (Widerruf, Art. 7(3))",
|
||||
"level": 2,
|
||||
"parent": "banner_consent_valid",
|
||||
"check_key": "re_access_settings",
|
||||
"severity": "MEDIUM",
|
||||
"hint": (
|
||||
"Art. 7(3) DSGVO: 'Der Widerruf der Einwilligung muss so einfach "
|
||||
"wie die Erteilung der Einwilligung sein.' Konkret muss ein "
|
||||
"persistenter Link zu den Cookie-Einstellungen vorhanden sein — "
|
||||
"typischerweise im Footer, als schwebendes Icon oder ueber ein "
|
||||
"Fingerprint-Symbol. Die DSK-Orientierungshilfe Telemedien "
|
||||
"(Dez. 2021) fordert dies explizit. Haeufiger Fehler: Nach "
|
||||
"Schliessung des Banners gibt es keinen Weg zurueck zu den "
|
||||
"Einstellungen — der Nutzer muesste Cookies manuell loeschen. "
|
||||
"Loesung: Permanenter Footer-Link oder CMP-Widget."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "consent_expiry_13m",
|
||||
"label": "Consent-Cookie max. 13 Monate gueltig (CNIL)",
|
||||
"level": 2,
|
||||
"parent": "banner_consent_valid",
|
||||
"check_key": "consent_cookie_expiry_13m",
|
||||
"severity": "MEDIUM",
|
||||
"hint": (
|
||||
"CNIL-Leitlinie (01.10.2020), Art. 5: 'La validite du "
|
||||
"consentement est de 13 mois maximum.' Die Consent-Entscheidung "
|
||||
"darf maximal 13 Monate (ca. 395 Tage) gespeichert werden — "
|
||||
"danach muss der Nutzer erneut gefragt werden. Auch die DSK-"
|
||||
"Orientierungshilfe Telemedien empfiehlt dies. Haeufiger Fehler: "
|
||||
"CMP-Default ist 12 Monate (365 Tage) — das ist im Rahmen. "
|
||||
"Aber manche setzen 2 Jahre oder 'Session' (kein Ablauf). "
|
||||
"Pruefung: Consent-Cookie Expiry-Datum vs. aktuelles Datum, "
|
||||
"Differenz darf 395 Tage nicht uebersteigen."
|
||||
),
|
||||
},
|
||||
|
||||
# =====================================================================
|
||||
# L1-5: Keine Vorab-Cookies/Tracking vor Consent
|
||||
# =====================================================================
|
||||
{
|
||||
"id": "banner_pre_consent",
|
||||
"label": "Kein Tracking vor Einwilligung (Phase A)",
|
||||
"level": 1,
|
||||
"parent": None,
|
||||
"check_key": "cookies_before_consent",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"ss25 Abs. 1 TDDDG (vormals ss15(3) TMG): Der Zugriff auf das "
|
||||
"Endgeraet des Nutzers (Cookie setzen, Script laden, "
|
||||
"Fingerprinting) ist NUR zulaessig, wenn der Nutzer zuvor "
|
||||
"eingewilligt hat. Tracking vor jeder Banner-Interaktion ist ein "
|
||||
"klarer Verstoss. EuGH C-673/17 (Planet49) und BGH (I ZR 7/16) "
|
||||
"bestaetigen: Die Einwilligung muss VOR dem Cookie-Setzen "
|
||||
"vorliegen. Haeufiger Fehler: Google Tag Manager laedt GA4 "
|
||||
"bereits beim Seitenaufruf — GTM selbst ist zulaessig, aber "
|
||||
"die darin konfigurierten Tags muessen consent-gesteuert sein."
|
||||
),
|
||||
},
|
||||
# ── L2 unter banner_pre_consent ──────────────────────────────────
|
||||
{
|
||||
"id": "no_tracking_scripts_before",
|
||||
"label": "Keine Tracking-Scripts vor Consent geladen",
|
||||
"level": 2,
|
||||
"parent": "banner_pre_consent",
|
||||
"check_key": "tracking_before_consent",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"ss25 Abs. 1 TDDDG, Art. 5(3) ePrivacy-RL: Auch das blosse "
|
||||
"Laden eines Tracking-Scripts (ohne Cookie) ist ein 'Zugriff "
|
||||
"auf das Endgeraet', weil dabei Informationen (IP, User-Agent, "
|
||||
"Viewport) uebermittelt werden. Dies gilt fuer GA4, Meta Pixel, "
|
||||
"TikTok Pixel, LinkedIn Insight Tag etc. Pruefung: Netzwerk-"
|
||||
"Requests in Phase A (vor Consent) auf Tracking-Domains pruefen. "
|
||||
"Haeufiger Fehler: CMP-Integration ueber Google Tag Manager, "
|
||||
"aber Consent Mode nicht korrekt konfiguriert — Tags feuern "
|
||||
"trotzdem."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_tracking_cookies_before",
|
||||
"label": "Keine Tracking-Cookies vor Consent gesetzt",
|
||||
"level": 2,
|
||||
"parent": "banner_pre_consent",
|
||||
"check_key": "cookies_before_consent",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"EuGH C-673/17 (Planet49), Rn. 61: Die Einwilligung muss 'vor "
|
||||
"dem Speichern von Informationen' eingeholt werden. Cookies wie "
|
||||
"_ga, _gid, _fbp, _fbc, IDE, _gcl_*, fr, _pin_*, _tt_*, "
|
||||
"li_sugr, _hj* duerfen erst NACH expliziter Einwilligung "
|
||||
"gesetzt werden. Pruefung: document.cookie in Phase A auf "
|
||||
"bekannte Tracking-Cookie-Patterns pruefen. Haeufiger Fehler: "
|
||||
"CMP setzt Consent-Default auf 'granted' — dann werden Cookies "
|
||||
"sofort gesetzt und erst bei Ablehnung geloescht (zu spaet)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "gcm_default_denied",
|
||||
"label": "Google Consent Mode Default = denied",
|
||||
"level": 2,
|
||||
"parent": "banner_pre_consent",
|
||||
"check_key": "google_consent_mode_defaults",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"Google Consent Mode v2: Die Standardwerte fuer analytics_storage, "
|
||||
"ad_storage, ad_user_data und ad_personalization muessen auf "
|
||||
"'denied' stehen, bis der Nutzer explizit einwilligt. Ein Default "
|
||||
"von 'granted' bedeutet, dass Google sofort Daten erhebt — das "
|
||||
"ist ein Verstoss gegen ss25 TDDDG. Pruefung: gtag('consent', "
|
||||
"'default', {...}) im Quelltext suchen und pruefen ob "
|
||||
"analytics_storage oder ad_storage auf 'granted' steht. "
|
||||
"Haeufiger Fehler: CMP-Plugin nicht korrekt konfiguriert, "
|
||||
"Default-Werte werden nicht ueberschrieben."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_facebook_pixel_before",
|
||||
"label": "Kein Meta/Facebook Pixel vor Consent",
|
||||
"level": 2,
|
||||
"parent": "banner_pre_consent",
|
||||
"check_key": "tracking_before_consent",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"Das Meta Pixel (ehem. Facebook Pixel) uebermittelt bei jedem "
|
||||
"Seitenaufruf personenbezogene Daten (IP, User-Agent, fbp/fbc-"
|
||||
"Cookies) an Meta Platforms Ireland Ltd. — einen Empfaenger in "
|
||||
"einem Land, das keinen Angemessenheitsbeschluss hat (Meta "
|
||||
"Schrems-II-Problematik). OeVGH (W211 2249247-1, 'noyb vs. "
|
||||
"Meta'): Uebermittlung an Meta ohne Einwilligung ist rechtswidrig. "
|
||||
"Das Pixel darf ERST nach expliziter Einwilligung geladen werden. "
|
||||
"Pruefung: Netzwerk-Requests an connect.facebook.net oder "
|
||||
"facebook.com/tr in Phase A."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_google_analytics_before",
|
||||
"label": "Kein Google Analytics vor Consent",
|
||||
"level": 2,
|
||||
"parent": "banner_pre_consent",
|
||||
"check_key": "tracking_before_consent",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"Mehrere EU-Datenschutzbehoerden haben den Einsatz von Google "
|
||||
"Analytics ohne Einwilligung fuer rechtswidrig erklaert: "
|
||||
"oesterreichische DSB (Bescheid 2021-0.586.257, 'noyb-Beschwerde'), "
|
||||
"franzoesische CNIL (Mise en demeure, Feb. 2022), italienische "
|
||||
"GPDP (Provvedimento 9782890, Juni 2022). GA4 darf erst NACH "
|
||||
"Consent geladen werden. Auch mit Server-Side-Tagging oder "
|
||||
"IP-Anonymisierung bleibt ein personenbezogener Datentransfer "
|
||||
"bestehen. Pruefung: Requests an googletagmanager.com oder "
|
||||
"google-analytics.com in Phase A."
|
||||
),
|
||||
},
|
||||
|
||||
# =====================================================================
|
||||
# L1-6: Ablehnung wird respektiert (Phase B)
|
||||
# =====================================================================
|
||||
{
|
||||
"id": "banner_post_reject",
|
||||
"label": "Ablehnung wird respektiert (Phase B)",
|
||||
"level": 1,
|
||||
"parent": None,
|
||||
"check_key": "tracking_after_reject",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"ss25 Abs. 1 TDDDG: Wird die Einwilligung verweigert, darf "
|
||||
"KEIN nicht-essentieller Zugriff auf das Endgeraet erfolgen. "
|
||||
"Tracking, das nach Ablehnung weiterlaeuft, ist ein schwerer "
|
||||
"Verstoss — der Nutzer hat seinen Willen ausdruecklich erklaert. "
|
||||
"Die franzoesische CNIL hat in mehreren Entscheiden (u.a. "
|
||||
"SAN-2022-009, Criteo, 40 Mio. EUR) geruegt, dass Tracking "
|
||||
"trotz Ablehnung fortgesetzt wurde. Pruefung: Phase A vs. "
|
||||
"Phase B vergleichen — neue Tracking-Scripts oder Cookies "
|
||||
"nach Reject sind ein Verstoss."
|
||||
),
|
||||
},
|
||||
# ── L2 unter banner_post_reject ──────────────────────────────────
|
||||
{
|
||||
"id": "tracking_stops_after_reject",
|
||||
"label": "Tracking-Scripts werden nach Ablehnung entfernt",
|
||||
"level": 2,
|
||||
"parent": "banner_post_reject",
|
||||
"check_key": "tracking_after_reject",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"Nach Ablehnung duerfen keine neuen Tracking-Scripts geladen "
|
||||
"werden. Bestehende Scripts sollten deaktiviert oder entfernt "
|
||||
"werden. ss25 TDDDG kennt keinen 'Bestandsschutz' fuer bereits "
|
||||
"geladene Tracker — auch wenn ein Script in Phase A (vor Consent) "
|
||||
"fehlerhaft geladen wurde, muss es nach Reject gestoppt werden. "
|
||||
"Pruefung: Netzwerk-Requests in Phase B auf neue Tracking-Domains "
|
||||
"pruefen. Haeufiger Fehler: GA4 Enhanced Measurement sendet "
|
||||
"weiterhin scroll/outbound-Events trotz Ablehnung, weil der "
|
||||
"Consent Mode 'update'-Befehl nicht korrekt feuert."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "cookies_removed_after_reject",
|
||||
"label": "Tracking-Cookies nach Ablehnung entfernt",
|
||||
"level": 2,
|
||||
"parent": "banner_post_reject",
|
||||
"check_key": "tracking_after_reject",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"Wenn in Phase A faelschlicherweise Tracking-Cookies gesetzt "
|
||||
"wurden, muessen diese nach Ablehnung geloescht werden. Ein CMP "
|
||||
"sollte bei Reject ein 'Cookie-Cleanup' durchfuehren: _ga, _gid, "
|
||||
"_fbp etc. entfernen. Die CNIL-Leitlinie (Okt. 2020), Rn. 23 "
|
||||
"verlangt, dass 'der Verantwortliche sicherstellt, dass die "
|
||||
"Ablehnung effektiv umgesetzt wird'. Pruefung: Cookie-Liste vor "
|
||||
"und nach Reject vergleichen — Tracking-Cookies sollten "
|
||||
"verschwunden sein."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "no_new_tracking_after_reject",
|
||||
"label": "Keine neuen Tracker nach Ablehnung",
|
||||
"level": 2,
|
||||
"parent": "banner_post_reject",
|
||||
"check_key": "tracking_after_reject",
|
||||
"severity": "CRITICAL",
|
||||
"hint": (
|
||||
"Der gravierendste Verstoss: NACH expliziter Ablehnung werden "
|
||||
"NEUE Tracking-Services geladen, die vorher nicht aktiv waren. "
|
||||
"Dies kann passieren, wenn das CMP den Reject-Status nicht "
|
||||
"korrekt an den Tag Manager weitergibt oder wenn hardcoded "
|
||||
"Scripts im HTML stehen, die nicht consent-gesteuert sind. "
|
||||
"CNIL SAN-2022-009 (Criteo, 40 Mio. EUR): 'Des traceurs "
|
||||
"continuaient d'etre deposés malgre le refus de l'utilisateur.' "
|
||||
"Pruefung: Diff der Tracking-Services zwischen Phase A und "
|
||||
"Phase B — neue Eintraege sind ein Verstoss."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "site_functional_after_reject",
|
||||
"label": "Seite bleibt nutzbar nach Ablehnung (kein Cookie-Wall)",
|
||||
"level": 2,
|
||||
"parent": "banner_post_reject",
|
||||
"check_key": "cookie_wall",
|
||||
"severity": "HIGH",
|
||||
"hint": (
|
||||
"EDPB Guidelines 05/2020, Rn. 39: 'Access to services and "
|
||||
"functionalities must not be made conditional on the consent "
|
||||
"of a user to the storing of information.' Eine sogenannte "
|
||||
"'Cookie Wall', die den Zugang zur Website nach Ablehnung "
|
||||
"vollstaendig blockiert, macht die Einwilligung unfreiwillig. "
|
||||
"Ausnahme: 'Consent or Pay'-Modelle (EDPB Opinion 08/2024) "
|
||||
"sind unter engen Bedingungen zulaessig — dafuer muss die "
|
||||
"Bezahlalternative 'angemessen' sein und darf nicht "
|
||||
"ueberzogen sein. Haeufiger Fehler: Website zeigt nach "
|
||||
"Ablehnung eine leere Seite oder Redirect auf Fehlerseite."
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -220,6 +220,40 @@ async def discover_dsi_documents(
|
||||
await page.goto(url, wait_until="networkidle", timeout=60000)
|
||||
await page.wait_for_timeout(2000)
|
||||
|
||||
# Step 1b: Self-extraction — if the URL itself is a DSI page,
|
||||
# extract its full text as the first document. This handles the
|
||||
# case where the user provides the DSE URL directly (e.g.
|
||||
# example.com/datenschutz) instead of the homepage.
|
||||
current_url_path = urlparse(url).path.lower()
|
||||
is_self_dsi, self_lang = _matches_dsi_keyword(current_url_path)
|
||||
if not is_self_dsi:
|
||||
# Also check the page title
|
||||
page_title = await page.title() or ""
|
||||
is_self_dsi, self_lang = _matches_dsi_keyword(page_title)
|
||||
if is_self_dsi:
|
||||
try:
|
||||
self_text = await page.evaluate("""() => {
|
||||
const main = document.querySelector('main, article, [role="main"], .content, #content, .bodytext')
|
||||
|| document.body;
|
||||
return main ? main.innerText : document.body.innerText;
|
||||
}""")
|
||||
self_wc = len(self_text.split()) if self_text else 0
|
||||
if self_wc >= 100:
|
||||
page_title = await page.title() or url
|
||||
result.documents.append(DiscoveredDSI(
|
||||
title=page_title.strip(),
|
||||
url=url,
|
||||
source_url=url,
|
||||
language=self_lang or "de",
|
||||
doc_type="html_full_page",
|
||||
text=self_text.strip(),
|
||||
word_count=self_wc,
|
||||
))
|
||||
seen_urls.add(url)
|
||||
logger.info("Self-extracted %d words from %s", self_wc, url)
|
||||
except Exception as e:
|
||||
logger.warning("Self-extraction failed for %s: %s", url, e)
|
||||
|
||||
# Step 2: Find DSI links in current page
|
||||
links = await _find_dsi_links(page, base_domain)
|
||||
logger.info("Found %d DSI links on %s", len(links), url)
|
||||
@@ -360,8 +394,9 @@ async def discover_dsi_documents(
|
||||
return result
|
||||
|
||||
# Nav elements, not real documents
|
||||
# NOTE: "datenschutz" was removed — it's a legitimate document title
|
||||
NOISE_TITLES = {"drucken", "print", "nach oben", "back to top", "teilen", "share",
|
||||
"kontakt", "contact", "suche", "search", "menü", "menu", "home", "datenschutz"}
|
||||
"kontakt", "contact", "suche", "search", "menü", "menu", "home"}
|
||||
|
||||
def _deduplicate_documents(docs: list[DiscoveredDSI]) -> list[DiscoveredDSI]:
|
||||
"""Remove duplicate and noise documents."""
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Plan: Banner-Check auf Dokumentenpruefungs-Qualitaet upgraden
|
||||
|
||||
## Ziel
|
||||
|
||||
Die 22 bestehenden Banner-Checks auf das gleiche Qualitaetsniveau bringen
|
||||
wie die 138 Dokumenten-Checks: L1/L2-Hierarchie, Expert-Level Hints mit
|
||||
EuGH/CNIL/DSK-Referenzen, strukturiertes CheckItem-Format.
|
||||
|
||||
## Bestehende 22 Checks (Inventar)
|
||||
|
||||
### Banner Text & Verhalten (1-11)
|
||||
1. `impressum_link` — Impressum aus Banner erreichbar
|
||||
2. `dse_link` — DSE-Link im Banner
|
||||
3. `wrong_dse_consent` — Falsche Formulierung ("Zustimmung zur DSE")
|
||||
4. `reject_button_visible` — Ablehnen-Button sichtbar
|
||||
5. `pre_ticked_checkboxes` — Vorausgewaehlte Checkboxen (EuGH Planet49)
|
||||
6. `dark_pattern_button_size` — Akzeptieren groesser als Ablehnen
|
||||
7. `cookie_wall` — Seite nach Ablehnung nutzbar
|
||||
8. `re_access_settings` — Einstellungen erneut zugaenglich
|
||||
9. `third_party_dse_link` — DSE zeigt auf eigene Seite
|
||||
10. `dark_pattern_language` — Manipulative Sprache
|
||||
11. `non_modal_dismiss` — Klick ausserhalb = keine Einwilligung
|
||||
|
||||
### Advanced Compliance (12-20)
|
||||
12. `click_count_asymmetry` — Gleiche Klickanzahl fuer Accept/Reject
|
||||
13. `color_contrast_dark_pattern` — Ablehnen-Button nicht unsichtbar
|
||||
14. `google_consent_mode_defaults` — GCM Default = denied
|
||||
15. `cookies_before_consent` — Keine Cookies vor Consent
|
||||
16. `registration_consent_coupling` — Koppelungsverbot Art. 7(4)
|
||||
17. `banner_language_mismatch` — Sprache = Seitensprache
|
||||
18. `consent_cookie_expiry_13m` — Max 13 Monate (CNIL)
|
||||
19. `nudging_reject_hidden` — Ablehnen nicht versteckt
|
||||
20. `stirring_emotional_language` — Emotionale Manipulation
|
||||
|
||||
### Phasen-basiert (21-22)
|
||||
21. `tracking_before_consent` — Tracking vor Einwilligung
|
||||
22. `tracking_after_reject` — Tracking nach Ablehnung
|
||||
|
||||
## Geplante L1/L2-Struktur
|
||||
|
||||
### L1: Banner-Grundanforderungen (6 Checks)
|
||||
|
||||
| ID | Label | Prueft |
|
||||
|----|-------|--------|
|
||||
| banner_present | Banner vorhanden | Wird ein Cookie-Banner angezeigt? |
|
||||
| banner_choices | Wahlmoeglichkeit | Akzeptieren UND Ablehnen moeglich? |
|
||||
| banner_legal_links | Rechtliche Links | Impressum + DSE erreichbar? |
|
||||
| banner_consent_valid | Gueltige Einwilligung | Keine Pre-Ticked Boxes, kein Auto-Consent? |
|
||||
| banner_pre_consent | Keine Vorab-Cookies | Keine Tracking-Cookies vor Consent? |
|
||||
| banner_post_reject | Ablehnung respektiert | Tracking stoppt nach Ablehnung? |
|
||||
|
||||
### L2: Detail-Checks pro L1 (30+ Checks)
|
||||
|
||||
#### Unter "banner_choices" (Wahlmoeglichkeit):
|
||||
- reject_visible — Ablehnen-Button sichtbar auf erster Ebene
|
||||
- reject_same_clicks — Gleiche Klickanzahl wie Akzeptieren
|
||||
- reject_same_size — Gleiche Buttongroesse (kein Dark Pattern)
|
||||
- reject_same_prominence — Gleiche Farbgebung/Kontrast
|
||||
- reject_no_scroll — Ablehnen ohne Scrollen erreichbar
|
||||
- reject_no_nudging — Kein Nudging/Stirring
|
||||
|
||||
#### Unter "banner_legal_links":
|
||||
- impressum_accessible — Impressum trotz Overlay erreichbar
|
||||
- dse_link_present — DSE-Link im Banner vorhanden
|
||||
- dse_link_own — DSE zeigt auf eigene Seite (nicht Drittanbieter)
|
||||
- dse_readable — DSE ist vor Einwilligung einsehbar
|
||||
|
||||
#### Unter "banner_consent_valid":
|
||||
- no_pre_ticked — Keine vorausgewaehlten Checkboxen
|
||||
- no_wrong_wording — Keine "Zustimmung zur DSE"
|
||||
- no_modal_dismiss — Klick ausserhalb != Einwilligung
|
||||
- no_coupling — Kein Koppelungsverbot-Verstoss
|
||||
- no_emotional_language — Keine manipulative Sprache
|
||||
- consent_revocable — Einstellungen erneut zugaenglich (Art. 7(3))
|
||||
- consent_expiry — Consent-Cookie max 13 Monate (CNIL)
|
||||
|
||||
#### Unter "banner_pre_consent":
|
||||
- no_tracking_scripts — Keine Tracking-Scripts vor Consent
|
||||
- no_tracking_cookies — Keine Tracking-Cookies vor Consent
|
||||
- gcm_default_denied — Google Consent Mode Default = denied
|
||||
- no_facebook_pixel — Kein Meta Pixel vor Consent
|
||||
- no_google_analytics — Kein GA vor Consent
|
||||
|
||||
#### Unter "banner_post_reject":
|
||||
- tracking_stops — Tracking-Scripts entfernt nach Ablehnung
|
||||
- cookies_removed — Tracking-Cookies entfernt nach Ablehnung
|
||||
- no_new_tracking — Keine neuen Tracker nach Ablehnung
|
||||
- site_functional — Seite bleibt nutzbar (kein Cookie-Wall)
|
||||
|
||||
#### Unter "banner_present":
|
||||
- banner_language — Sprache = Seitensprache
|
||||
- banner_provider_named — CMP-Anbieter identifizierbar
|
||||
|
||||
## Expert-Level Hints (Beispiele)
|
||||
|
||||
### Dark Pattern Button-Groesse:
|
||||
"EDPB Guidelines 3/2022 (Deceptive Design Patterns), Rn. 62: Akzeptieren
|
||||
und Ablehnen muessen 'gleichwertig praesentiert' werden. Konkret: Gleiche
|
||||
Schriftgroesse, Buttongroesse und Farbprominenz. Die CNIL hat Google
|
||||
(150 Mio. EUR) und Facebook (60 Mio. EUR) u.a. wegen Klick-Asymmetrie
|
||||
bestraft."
|
||||
|
||||
### Pre-Consent Tracking:
|
||||
"§25 Abs. 1 TDDDG: Zugriff auf Endgeraet (Cookie setzen, Script laden)
|
||||
erst NACH informierter Einwilligung. Tracking vor Banner-Interaktion ist
|
||||
ein Verstoss. Haeufig: Google Tag Manager laedt GA4 bereits beim
|
||||
Seitenaufruf — GTM selbst ist erlaubt, die darin konfigurierten Tags
|
||||
muessen aber consent-gesteuert sein."
|
||||
|
||||
### Cookie-Wall:
|
||||
"EDPB Guidelines 05/2020, Rn. 39: Verweigerung der Einwilligung darf
|
||||
nicht dazu fuehren, dass die Website nicht mehr nutzbar ist (sog.
|
||||
'Cookie Wall'). Ausnahme: Paywall-Modelle ('Consent or Pay') sind
|
||||
nach EDPB Opinion 08/2024 unter engen Bedingungen zulaessig."
|
||||
|
||||
### Consent-Cookie Laufzeit:
|
||||
"CNIL-Leitlinie (Dez. 2020): Consent-Entscheidung darf max. 13 Monate
|
||||
gespeichert werden. Danach muss erneut gefragt werden. Viele CMPs
|
||||
setzen Default auf 365 Tage — das ist noch im Rahmen. Ueber 395 Tage
|
||||
ist ein Verstoss."
|
||||
|
||||
## Technische Umsetzung
|
||||
|
||||
### Phase 1: Banner-Check Datenmodell (Backend)
|
||||
- Neues Package `consent-tester/checks/` mit L1/L2-Struktur
|
||||
- Jeder Check bekommt: id, label, level, parent, severity, hint
|
||||
- Runner aggregiert Checks und berechnet Scores
|
||||
- Response-Format analog zu DocCheckResult (completeness_pct, correctness_pct)
|
||||
|
||||
### Phase 2: Consent-Tester Integration
|
||||
- Bestehende Checks (banner_text_checker, banner_advanced_checks) mappen
|
||||
auf neue L1/L2-Struktur
|
||||
- Phasen-Violations als L2-Checks unter banner_pre_consent / banner_post_reject
|
||||
- Tracking-Services als Evidence (matched_text)
|
||||
|
||||
### Phase 3: Frontend
|
||||
- BannerCheckTab zeigt L1/L2 hierarchisch (wie ChecklistView)
|
||||
- 3-Phasen-Zusammenfassung oben
|
||||
- Fortschrittsbalken (gruen/blau)
|
||||
- Hints unter fehlgeschlagenen Checks
|
||||
|
||||
### Phase 4: Email-Report
|
||||
- Gleiche HTML-Formatierung wie Dokumentenpruefung
|
||||
- Hints + Evidence + Phase-Zuordnung
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Aktion |
|
||||
|-------|--------|
|
||||
| consent-tester/checks/__init__.py | Neu: L1/L2 Check-Definitionen |
|
||||
| consent-tester/checks/banner_checks.py | Neu: 6 L1 + 30 L2 Checks mit Hints |
|
||||
| consent-tester/checks/runner.py | Neu: Aggregation + Scoring |
|
||||
| consent-tester/services/consent_scanner.py | Anpassen: Neues Response-Format |
|
||||
| backend-compliance/compliance/api/agent_doc_check_routes.py | Erweitern: Banner-Result-Mapping |
|
||||
| admin-compliance/app/sdk/agent/_components/BannerCheckTab.tsx | Erweitern: ChecklistView-Integration |
|
||||
|
||||
## Geschaetzter Aufwand
|
||||
|
||||
~3-4 Stunden:
|
||||
- 1h: L1/L2 Check-Definitionen + 36 Expert-Level Hints
|
||||
- 1h: Consent-Tester Mapping + Runner
|
||||
- 0.5h: Backend Response-Mapping
|
||||
- 0.5h: Frontend Integration
|
||||
- 0.5h: Test + Deploy + Ground Truth
|
||||
Reference in New Issue
Block a user