feat(vendor-assessment): E2E tests + remove old use-case-audit
Build + Deploy / build-backend-compliance (push) Successful in 12s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-tts (push) Successful in 11s
Build + Deploy / build-document-crawler (push) Successful in 12s
Build + Deploy / build-admin-compliance (push) Successful in 1m51s
Build + Deploy / build-ai-sdk (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 11s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Build + Deploy / build-dsms-node (push) Successful in 15s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 15s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m25s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 40s
CI / test-python-backend (push) Successful in 44s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 20s
CI / validate-canonical-controls (push) Successful in 13s
Build + Deploy / trigger-orca (push) Successful in 2m25s
Build + Deploy / build-backend-compliance (push) Successful in 12s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-tts (push) Successful in 11s
Build + Deploy / build-document-crawler (push) Successful in 12s
Build + Deploy / build-admin-compliance (push) Successful in 1m51s
Build + Deploy / build-ai-sdk (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 11s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Build + Deploy / build-dsms-node (push) Successful in 15s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 15s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m25s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 40s
CI / test-python-backend (push) Successful in 44s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 20s
CI / validate-canonical-controls (push) Successful in 13s
Build + Deploy / trigger-orca (push) Successful in 2m25s
Phase 6-7: Remove /sdk/use-case-audit (questionnaire approach), replace sidebar with "Vertragspruefung". Add Playwright E2E tests: - Page load & form validation tests - Spiegel.de DSE assessment (real URL) - IHK Berlin multi-document assessment (DSE + Impressum) - Hetzner AVV auto-detect test - API direct tests (POST, GET, poll, not-found) - Cross-check scenario (AVV without TOM → missing TOM finding) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Use-Case Compiler API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/use-case/* requests to ai-compliance-sdk backend
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${SDK_BACKEND_URL}/sdk/v1/use-case`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '00000000-0000-0000-0000-000000000001'
|
||||
const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
headers['X-Tenant-Id'] = request.headers.get('x-tenant-id') || DEFAULT_TENANT
|
||||
headers['X-User-Id'] = request.headers.get('x-user-id') || DEFAULT_USER
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text && text.trim()) {
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Use-Case API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { QuestionnaireView } from '../_components/QuestionnaireView'
|
||||
import { AuditResult } from '../_components/AuditResult'
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
mc_id: string
|
||||
mc_name: string
|
||||
question: string
|
||||
question_type: string
|
||||
evidence_required: boolean
|
||||
pass_criteria: string[]
|
||||
fail_criteria: string[]
|
||||
severity: string
|
||||
regulation: string
|
||||
depends_on?: string
|
||||
}
|
||||
|
||||
interface Audit {
|
||||
id: string
|
||||
tenant_id: string
|
||||
template_id: string
|
||||
name: string
|
||||
target_name: string
|
||||
status: string
|
||||
total_questions: number
|
||||
answered_questions: number
|
||||
compliance_score: number
|
||||
questions: Question[]
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
id: string
|
||||
question_id: string
|
||||
mc_id: string
|
||||
value: unknown
|
||||
comment: string
|
||||
evidence_ids: string[]
|
||||
status: string
|
||||
answered_at: string
|
||||
}
|
||||
|
||||
interface ScoreResult {
|
||||
audit_id: string
|
||||
total_questions: number
|
||||
answered: number
|
||||
passed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
compliance_score: number
|
||||
by_regulation: Record<string, { total: number; passed: number; score: number }>
|
||||
by_severity: Record<string, { total: number; passed: number; failed: number }>
|
||||
}
|
||||
|
||||
const TENANT_ID = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
export default function AuditDetailPage() {
|
||||
const params = useParams()
|
||||
const auditId = params.auditId as string
|
||||
|
||||
const [audit, setAudit] = useState<Audit | null>(null)
|
||||
const [answers, setAnswers] = useState<Answer[]>([])
|
||||
const [score, setScore] = useState<ScoreResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadAudit = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/use-case/audits/${auditId}`, {
|
||||
headers: { 'X-Tenant-ID': TENANT_ID },
|
||||
})
|
||||
if (!res.ok) throw new Error('Audit nicht gefunden')
|
||||
const data = await res.json()
|
||||
setAudit(data.audit)
|
||||
setAnswers(data.answers || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [auditId])
|
||||
|
||||
useEffect(() => { loadAudit() }, [loadAudit])
|
||||
|
||||
const handleAnswer = async (questionId: string, value: unknown, comment: string) => {
|
||||
if (!audit) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/use-case/audits/${auditId}/answer`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
question_id: questionId,
|
||||
value,
|
||||
comment,
|
||||
status: 'answered',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Antwort konnte nicht gespeichert werden')
|
||||
const data = await res.json()
|
||||
|
||||
// Update local state
|
||||
setAnswers(prev => {
|
||||
const idx = prev.findIndex(a => a.question_id === questionId)
|
||||
const newAnswer = data.answer
|
||||
if (idx >= 0) {
|
||||
const copy = [...prev]
|
||||
copy[idx] = newAnswer
|
||||
return copy
|
||||
}
|
||||
return [...prev, newAnswer]
|
||||
})
|
||||
|
||||
if (data.progress) {
|
||||
setAudit(prev => prev ? {
|
||||
...prev,
|
||||
answered_questions: data.progress.answered,
|
||||
compliance_score: data.progress.compliance_score,
|
||||
status: data.progress.answered >= prev.total_questions ? 'completed' : 'in_progress',
|
||||
} : null)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = async (questionId: string) => {
|
||||
if (!audit) return
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/use-case/audits/${auditId}/answer`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
question_id: questionId,
|
||||
value: null,
|
||||
status: 'skipped',
|
||||
}),
|
||||
})
|
||||
await loadAudit()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const loadScore = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/use-case/audits/${auditId}/score`, {
|
||||
headers: { 'X-Tenant-ID': TENANT_ID },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setScore(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-500">Lade Audit...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !audit) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">{error || 'Audit nicht gefunden'}</p>
|
||||
<a href="/sdk/use-case-audit" className="text-sm text-red-500 mt-2 underline">
|
||||
Zurueck zur Liste
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showResult = audit.status === 'completed' || score !== null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/sdk/use-case-audit" className="text-sm text-blue-600 hover:text-blue-800 mb-1 inline-block">
|
||||
← Alle Audits
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{audit.name}</h1>
|
||||
{audit.target_name && (
|
||||
<p className="text-gray-600">{audit.target_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Progress indicator */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-500">
|
||||
{audit.answered_questions} / {audit.total_questions} beantwortet
|
||||
</div>
|
||||
<div className="w-32 h-2 bg-gray-200 rounded-full mt-1">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${audit.total_questions > 0 ? (audit.answered_questions / audit.total_questions) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{audit.answered_questions > 0 && (
|
||||
<button
|
||||
onClick={loadScore}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium"
|
||||
>
|
||||
Ergebnis anzeigen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showResult && score ? (
|
||||
<AuditResult audit={audit} score={score} />
|
||||
) : (
|
||||
<QuestionnaireView
|
||||
questions={audit.questions}
|
||||
answers={answers}
|
||||
onAnswer={handleAnswer}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface Audit {
|
||||
id: string
|
||||
name: string
|
||||
target_name: string
|
||||
total_questions: number
|
||||
answered_questions: number
|
||||
}
|
||||
|
||||
interface ScoreResult {
|
||||
audit_id: string
|
||||
total_questions: number
|
||||
answered: number
|
||||
passed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
compliance_score: number
|
||||
by_regulation: Record<string, { total: number; passed: number; score: number }>
|
||||
by_severity: Record<string, { total: number; passed: number; failed: number }>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
audit: Audit
|
||||
score: ScoreResult
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score >= 80) return 'text-green-600'
|
||||
if (score >= 50) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
function scoreBg(score: number): string {
|
||||
if (score >= 80) return 'bg-green-100 border-green-200'
|
||||
if (score >= 50) return 'bg-yellow-100 border-yellow-200'
|
||||
return 'bg-red-100 border-red-200'
|
||||
}
|
||||
|
||||
export function AuditResult({ audit, score }: Props) {
|
||||
const regEntries = Object.entries(score.by_regulation)
|
||||
const sevEntries = Object.entries(score.by_severity)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Score Overview */}
|
||||
<div className={`rounded-xl border p-6 text-center ${scoreBg(score.compliance_score)}`}>
|
||||
<div className={`text-5xl font-bold ${scoreColor(score.compliance_score)}`}>
|
||||
{Math.round(score.compliance_score)}%
|
||||
</div>
|
||||
<div className="text-gray-600 mt-2">Compliance Score</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{audit.name}{audit.target_name ? ` — ${audit.target_name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{score.answered}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Beantwortet</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{score.passed}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Bestanden</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{score.failed}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Nicht bestanden</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-400">{score.skipped}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Uebersprungen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Regulation */}
|
||||
{regEntries.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Nach Regulierung</h3>
|
||||
<div className="space-y-3">
|
||||
{regEntries.map(([reg, data]) => (
|
||||
<div key={reg} className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700 w-32 truncate">{reg}</span>
|
||||
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
data.score >= 80 ? 'bg-green-500' :
|
||||
data.score >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${data.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-sm font-bold w-12 text-right ${scoreColor(data.score)}`}>
|
||||
{Math.round(data.score)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 w-16 text-right">
|
||||
{data.passed}/{data.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* By Severity */}
|
||||
{sevEntries.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Nach Schweregrad</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{sevEntries.map(([sev, data]) => (
|
||||
<div key={sev} className="text-center">
|
||||
<div className={`text-sm font-medium mb-2 ${
|
||||
sev === 'HIGH' ? 'text-red-600' :
|
||||
sev === 'MEDIUM' ? 'text-yellow-600' : 'text-green-600'
|
||||
}`}>
|
||||
{sev}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">{data.passed}/{data.total}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{data.failed} nicht bestanden
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/sdk/use-case-audit"
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium"
|
||||
>
|
||||
Zurueck zur Liste
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
mc_id: string
|
||||
mc_name: string
|
||||
question: string
|
||||
question_type: string
|
||||
evidence_required: boolean
|
||||
pass_criteria: string[]
|
||||
fail_criteria: string[]
|
||||
severity: string
|
||||
regulation: string
|
||||
depends_on?: string
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
question_id: string
|
||||
value: unknown
|
||||
comment: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
questions: Question[]
|
||||
answers: Answer[]
|
||||
onAnswer: (questionId: string, value: unknown, comment: string) => void
|
||||
onSkip: (questionId: string) => void
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
HIGH: 'bg-red-100 text-red-700 border-red-200',
|
||||
MEDIUM: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
LOW: 'bg-green-100 text-green-700 border-green-200',
|
||||
}
|
||||
|
||||
export function QuestionnaireView({ questions, answers, onAnswer, onSkip }: Props) {
|
||||
const [comments, setComments] = useState<Record<string, string>>({})
|
||||
const [showCriteria, setShowCriteria] = useState<string | null>(null)
|
||||
|
||||
const answerMap = useMemo(() => {
|
||||
const m: Record<string, Answer> = {}
|
||||
for (const a of answers) { m[a.question_id] = a }
|
||||
return m
|
||||
}, [answers])
|
||||
|
||||
// Filter visible questions (dependency check)
|
||||
const visibleQuestions = useMemo(() => {
|
||||
return questions.filter(q => {
|
||||
if (!q.depends_on) return true
|
||||
const depAnswer = answerMap[q.depends_on]
|
||||
if (!depAnswer) return false
|
||||
return depAnswer.value === true || depAnswer.value === 'yes' || depAnswer.value === 'ja'
|
||||
})
|
||||
}, [questions, answerMap])
|
||||
|
||||
// Group by regulation
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, Question[]> = {}
|
||||
for (const q of visibleQuestions) {
|
||||
const key = q.regulation || 'Allgemein'
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(q)
|
||||
}
|
||||
return groups
|
||||
}, [visibleQuestions])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(grouped).map(([reg, qs]) => (
|
||||
<div key={reg}>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span className="px-2.5 py-0.5 bg-blue-100 text-blue-700 rounded text-sm font-medium">
|
||||
{reg}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400 font-normal">
|
||||
{qs.length} Fragen
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{qs.map((q, idx) => {
|
||||
const existing = answerMap[q.id]
|
||||
const isAnswered = !!existing && existing.status !== 'skipped'
|
||||
const isSkipped = existing?.status === 'skipped'
|
||||
const isPassed = existing?.value === true || existing?.value === 'yes'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={`bg-white rounded-xl border p-5 transition-all ${
|
||||
isAnswered
|
||||
? isPassed
|
||||
? 'border-green-200 bg-green-50/30'
|
||||
: 'border-red-200 bg-red-50/30'
|
||||
: isSkipped
|
||||
? 'border-gray-200 bg-gray-50/50 opacity-60'
|
||||
: 'border-gray-200 hover:border-blue-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xs text-gray-400 font-mono mt-1 shrink-0">
|
||||
{q.id}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<p className="text-sm font-medium text-gray-900">{q.question}</p>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${
|
||||
SEVERITY_COLORS[q.severity] || SEVERITY_COLORS.MEDIUM
|
||||
}`}>
|
||||
{q.severity}
|
||||
</span>
|
||||
{q.evidence_required && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
Nachweis
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{q.mc_name && (
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
MC: {q.mc_name} {q.mc_id && `(${q.mc_id})`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Criteria toggle */}
|
||||
<button
|
||||
onClick={() => setShowCriteria(showCriteria === q.id ? null : q.id)}
|
||||
className="text-xs text-blue-500 hover:text-blue-700 mb-2"
|
||||
>
|
||||
{showCriteria === q.id ? 'Kriterien ausblenden' : 'Bewertungskriterien anzeigen'}
|
||||
</button>
|
||||
|
||||
{showCriteria === q.id && (
|
||||
<div className="grid grid-cols-2 gap-3 mb-3 text-xs">
|
||||
{q.pass_criteria?.length > 0 && (
|
||||
<div className="bg-green-50 border border-green-100 rounded p-2">
|
||||
<div className="font-medium text-green-700 mb-1">Pass</div>
|
||||
{q.pass_criteria.map((c, i) => (
|
||||
<div key={i} className="text-green-600">{c}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{q.fail_criteria?.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-100 rounded p-2">
|
||||
<div className="font-medium text-red-700 mb-1">Fail</div>
|
||||
{q.fail_criteria.map((c, i) => (
|
||||
<div key={i} className="text-red-600">{c}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer controls */}
|
||||
{!isAnswered && !isSkipped && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => onAnswer(q.id, true, comments[q.id] || '')}
|
||||
className="px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
|
||||
>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAnswer(q.id, false, comments[q.id] || '')}
|
||||
className="px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm"
|
||||
>
|
||||
Nein
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSkip(q.id)}
|
||||
className="px-3 py-1.5 text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
Ueberspringen
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kommentar (optional)"
|
||||
value={comments[q.id] || ''}
|
||||
onChange={e => setComments(prev => ({ ...prev, [q.id]: e.target.value }))}
|
||||
className="flex-1 px-3 py-1.5 border border-gray-200 rounded-lg text-sm focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already answered */}
|
||||
{isAnswered && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm">
|
||||
<span className={isPassed ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
|
||||
{isPassed ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
{existing.comment && (
|
||||
<span className="text-gray-400">— {existing.comment}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSkipped && (
|
||||
<div className="text-sm text-gray-400 mt-2">Uebersprungen</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
mc_filters: string[]
|
||||
regulations: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
templates: Template[]
|
||||
onCreateAudit: (templateId: string, name: string, targetName: string) => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const REG_COLORS: Record<string, string> = {
|
||||
dsgvo: 'bg-purple-100 text-purple-700',
|
||||
nis2: 'bg-orange-100 text-orange-700',
|
||||
cra: 'bg-red-100 text-red-700',
|
||||
owasp: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
|
||||
export function UseCaseSelector({ templates, onCreateAudit, loading }: Props) {
|
||||
const [selected, setSelected] = useState<Template | null>(null)
|
||||
const [auditName, setAuditName] = useState('')
|
||||
const [targetName, setTargetName] = useState('')
|
||||
|
||||
const handleSelect = (t: Template) => {
|
||||
setSelected(t)
|
||||
setAuditName(t.name)
|
||||
setTargetName('')
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selected || !auditName.trim()) return
|
||||
onCreateAudit(selected.id, auditName.trim(), targetName.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!selected ? (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">Template auswaehlen</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{templates.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => handleSelect(t)}
|
||||
className="text-left bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition-all"
|
||||
>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{t.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{t.description}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{t.regulations.map(r => (
|
||||
<span
|
||||
key={r}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${REG_COLORS[r] || 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
{r.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs">
|
||||
{t.mc_filters.length} Filter
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{selected.name}</h2>
|
||||
<p className="text-sm text-gray-500">{selected.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Audit-Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={auditName}
|
||||
onChange={e => setAuditName(e.target.value)}
|
||||
placeholder={`z.B. "${selected.name}: Firma XY"`}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Zielobjekt (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={targetName}
|
||||
onChange={e => setTargetName(e.target.value)}
|
||||
placeholder="z.B. AWS, SAP, Produkt XY"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !auditName.trim()}
|
||||
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium text-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Wird erstellt...' : 'Audit starten'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="px-4 py-2.5 text-gray-600 hover:text-gray-800 text-sm"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { UseCaseSelector } from './_components/UseCaseSelector'
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
mc_filters: string[]
|
||||
regulations: string[]
|
||||
}
|
||||
|
||||
interface AuditSummary {
|
||||
id: string
|
||||
template_id: string
|
||||
name: string
|
||||
target_name: string
|
||||
status: string
|
||||
total_questions: number
|
||||
answered_questions: number
|
||||
compliance_score: number
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
const TENANT_ID = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
draft: { label: 'Entwurf', color: 'bg-gray-100 text-gray-700' },
|
||||
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
|
||||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
||||
}
|
||||
|
||||
export default function UseCaseAuditPage() {
|
||||
const [view, setView] = useState<'list' | 'new'>('list')
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [audits, setAudits] = useState<AuditSummary[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [tRes, aRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/use-case/templates'),
|
||||
fetch('/api/sdk/v1/use-case/audits', {
|
||||
headers: { 'X-Tenant-ID': TENANT_ID },
|
||||
}),
|
||||
])
|
||||
if (tRes.ok) {
|
||||
const td = await tRes.json()
|
||||
setTemplates(td.templates || [])
|
||||
}
|
||||
if (aRes.ok) {
|
||||
const ad = await aRes.json()
|
||||
setAudits(ad.audits || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleCreateAudit = async (templateId: string, name: string, targetName: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/use-case/audits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
template_id: templateId,
|
||||
name,
|
||||
target_name: targetName,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Audit konnte nicht erstellt werden')
|
||||
}
|
||||
const data = await res.json()
|
||||
window.location.href = `/sdk/use-case-audit/${data.audit.id}`
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Use-Case Audits</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Compliance-Pruefungen aus Master Controls — interaktive Frageboegen mit Scoring.
|
||||
</p>
|
||||
</div>
|
||||
{view === 'list' ? (
|
||||
<button
|
||||
onClick={() => setView('new')}
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium text-sm"
|
||||
>
|
||||
+ Neuen Audit starten
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className="px-4 py-2 text-sm text-blue-600 hover:text-blue-800 border border-blue-200 rounded-lg hover:bg-blue-50"
|
||||
>
|
||||
Zurueck zur Liste
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">{error}</p>
|
||||
<button onClick={() => setError('')} className="text-sm text-red-500 mt-1 underline">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'new' && (
|
||||
<UseCaseSelector
|
||||
templates={templates}
|
||||
onCreateAudit={handleCreateAudit}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'list' && (
|
||||
<div>
|
||||
{audits.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{audits.map(a => {
|
||||
const st = STATUS_LABELS[a.status] || STATUS_LABELS.draft
|
||||
const progress = a.total_questions > 0
|
||||
? Math.round((a.answered_questions / a.total_questions) * 100)
|
||||
: 0
|
||||
return (
|
||||
<a
|
||||
key={a.id}
|
||||
href={`/sdk/use-case-audit/${a.id}`}
|
||||
className="block bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{a.name}</h3>
|
||||
{a.target_name && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">{a.target_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${st.color}`}>
|
||||
{st.label}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
{a.answered_questions}/{a.total_questions}
|
||||
</div>
|
||||
<div className="w-20 h-1.5 bg-gray-200 rounded-full mt-1">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{a.status === 'completed' && (
|
||||
<div className={`text-lg font-bold ${
|
||||
a.compliance_score >= 80 ? 'text-green-600' :
|
||||
a.compliance_score >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{Math.round(a.compliance_score)}%
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(a.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white rounded-xl border border-gray-200">
|
||||
<div className="text-4xl mb-4">📋</div>
|
||||
<h3 className="text-lg font-medium text-gray-700 mb-2">Noch keine Audits</h3>
|
||||
<p className="text-gray-500 mb-6">Starten Sie Ihren ersten Compliance-Audit.</p>
|
||||
<button
|
||||
onClick={() => setView('new')}
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium text-sm"
|
||||
>
|
||||
Jetzt starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Vendor Assessment — Vertragspruefung E2E Tests
|
||||
*
|
||||
* Tests the complete flow: Provider → Documents → Analysis → Pruefprotokoll
|
||||
* Uses real provider URLs (DSE/AGB pages) as test documents.
|
||||
*
|
||||
* Test Vendors:
|
||||
* - Spiegel.de (large publisher, comprehensive DSE)
|
||||
* - IHK (institutional, formal AGB/DSE)
|
||||
* - Safetykon (smaller provider, potentially incomplete)
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'https://macmini:3007'
|
||||
|
||||
// ── Page Load & Navigation ─────────────────────────────────────────
|
||||
|
||||
test.describe('Vendor Assessment — Page', () => {
|
||||
test('page loads and shows upload form', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const body = await page.textContent('body')
|
||||
expect(body).toContain('Vertragspruefung')
|
||||
expect(body).toContain('Auftragsverarbeiter')
|
||||
expect(body).toContain('Dokumente')
|
||||
expect(body).toContain('Pruefung starten')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-page.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('sidebar shows Vertragspruefung link', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const sidebar = page.locator('nav, [class*="sidebar"], [class*="Sidebar"]')
|
||||
const sidebarText = await sidebar.textContent()
|
||||
expect(sidebarText).toContain('Vertragspruefung')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-sidebar.png',
|
||||
})
|
||||
})
|
||||
|
||||
test('can add and remove document entries', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Initially one entry
|
||||
const urlInputs = page.locator('input[type="url"]')
|
||||
await expect(urlInputs).toHaveCount(1)
|
||||
|
||||
// Add another
|
||||
await page.click('button:has-text("Weiteres Dokument")')
|
||||
await expect(urlInputs).toHaveCount(2)
|
||||
|
||||
// Add third
|
||||
await page.click('button:has-text("Weiteres Dokument")')
|
||||
await expect(urlInputs).toHaveCount(3)
|
||||
|
||||
// Remove second (click × button)
|
||||
const removeButtons = page.locator('button:has-text("×")')
|
||||
await removeButtons.nth(1).click()
|
||||
await expect(urlInputs).toHaveCount(2)
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-multi-doc.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('submit button disabled without vendor name', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const submitBtn = page.locator('button:has-text("Pruefung starten")')
|
||||
await expect(submitBtn).toBeDisabled()
|
||||
|
||||
// Fill vendor name only
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'Test GmbH')
|
||||
// Still disabled — no URL
|
||||
await expect(submitBtn).toBeDisabled()
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-validation.png',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Real Vendor Assessment Flows ───────────────────────────────────
|
||||
|
||||
test.describe('Vendor Assessment — Spiegel.de', () => {
|
||||
test.setTimeout(120000) // 2 min for full analysis
|
||||
|
||||
test('assess Spiegel DSE produces findings', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Fill vendor
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'Spiegel Verlag')
|
||||
|
||||
// Fill document URL
|
||||
await page.fill('input[type="url"]', 'https://www.spiegel.de/datenschutz-spiegel')
|
||||
|
||||
// Select doc type
|
||||
await page.selectOption('select', 'dse')
|
||||
|
||||
// Start assessment
|
||||
await page.click('button:has-text("Pruefung starten")')
|
||||
|
||||
// Wait for progress indicator
|
||||
await expect(page.locator('text=Vertragspruefung laeuft')).toBeVisible({ timeout: 10000 })
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-spiegel-progress.png',
|
||||
})
|
||||
|
||||
// Wait for completion (poll)
|
||||
await expect(page.locator('text=Spiegel Verlag')).toBeVisible({ timeout: 90000 })
|
||||
|
||||
// Pruefprotokoll should show score
|
||||
const body = await page.textContent('body')
|
||||
expect(body).toMatch(/\d+%/) // score percentage
|
||||
|
||||
// Should have document results
|
||||
expect(body).toContain('Gepruefte Dokumente')
|
||||
|
||||
// Should show DSE checks (since we submitted a DSE)
|
||||
expect(body).toContain('Dokumente')
|
||||
expect(body).toContain('Findings')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-spiegel-result.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Vendor Assessment — IHK', () => {
|
||||
test.setTimeout(120000)
|
||||
|
||||
test('assess IHK with DSE and AGB', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Fill vendor
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'IHK Berlin')
|
||||
|
||||
// First doc: DSE
|
||||
const firstUrl = page.locator('input[type="url"]').first()
|
||||
await firstUrl.fill('https://www.ihk.de/datenschutzerklaerung')
|
||||
await page.selectOption('select', 'dse')
|
||||
|
||||
// Add second doc: AGB
|
||||
await page.click('button:has-text("Weiteres Dokument")')
|
||||
const secondUrl = page.locator('input[type="url"]').nth(1)
|
||||
await secondUrl.fill('https://www.ihk.de/impressum')
|
||||
|
||||
// Second doc type: impressum
|
||||
const selects = page.locator('select')
|
||||
await selects.nth(1).selectOption('impressum')
|
||||
|
||||
// Start
|
||||
await page.click('button:has-text("Pruefung starten")')
|
||||
|
||||
// Wait for completion
|
||||
await expect(page.locator('text=IHK Berlin')).toBeVisible({ timeout: 90000 })
|
||||
|
||||
const body = await page.textContent('body')
|
||||
expect(body).toMatch(/\d+%/)
|
||||
expect(body).toContain('Gepruefte Dokumente')
|
||||
|
||||
// Should have 2 documents analyzed
|
||||
expect(body).toContain('2')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-ihk-result.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Vendor Assessment — AVV Check', () => {
|
||||
test.setTimeout(120000)
|
||||
|
||||
test('AVV document runs Art. 28 checks', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Fill vendor
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'Hetzner Online GmbH')
|
||||
|
||||
// Hetzner has a public AVV
|
||||
const urlInput = page.locator('input[type="url"]').first()
|
||||
await urlInput.fill('https://www.hetzner.com/de/legal/privacy-policy/')
|
||||
|
||||
// Auto-detect type
|
||||
await page.selectOption('select', 'auto')
|
||||
|
||||
// Start
|
||||
await page.click('button:has-text("Pruefung starten")')
|
||||
|
||||
// Wait for completion
|
||||
await expect(page.locator('text=Hetzner Online GmbH')).toBeVisible({ timeout: 90000 })
|
||||
|
||||
const body = await page.textContent('body')
|
||||
expect(body).toMatch(/\d+%/)
|
||||
|
||||
// Should show at least some category scores
|
||||
expect(body).toContain('Kategorie')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-hetzner-result.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── API Direct Tests ───────────────────────────────────────────────
|
||||
|
||||
test.describe('Vendor Assessment — API', () => {
|
||||
test('POST /assessments starts a job', async ({ request }) => {
|
||||
const resp = await request.post(`${BASE}/api/vendor-compliance/assessments`, {
|
||||
data: {
|
||||
vendor_name: 'API Test GmbH',
|
||||
documents: [
|
||||
{ doc_type: 'dse', label: 'Test DSE', url: 'https://www.spiegel.de/datenschutz-spiegel' },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(resp.ok()).toBeTruthy()
|
||||
|
||||
const data = await resp.json()
|
||||
expect(data.assessment_id).toBeTruthy()
|
||||
expect(data.status).toBe('running')
|
||||
})
|
||||
|
||||
test('GET /assessments/{id} returns status', async ({ request }) => {
|
||||
// Start a job first
|
||||
const startResp = await request.post(`${BASE}/api/vendor-compliance/assessments`, {
|
||||
data: {
|
||||
vendor_name: 'Poll Test GmbH',
|
||||
documents: [
|
||||
{ doc_type: 'dse', label: 'Test', url: 'https://www.spiegel.de/datenschutz-spiegel' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const { assessment_id } = await startResp.json()
|
||||
|
||||
// Poll immediately — should be running
|
||||
const statusResp = await request.get(
|
||||
`${BASE}/api/vendor-compliance/assessments/${assessment_id}`,
|
||||
)
|
||||
expect(statusResp.ok()).toBeTruthy()
|
||||
|
||||
const status = await statusResp.json()
|
||||
expect(status.assessment_id).toBe(assessment_id)
|
||||
expect(['running', 'completed']).toContain(status.status)
|
||||
})
|
||||
|
||||
test('GET /assessments lists all assessments', async ({ request }) => {
|
||||
const resp = await request.get(`${BASE}/api/vendor-compliance/assessments`)
|
||||
expect(resp.ok()).toBeTruthy()
|
||||
|
||||
const data = await resp.json()
|
||||
expect(data.assessments).toBeDefined()
|
||||
expect(Array.isArray(data.assessments)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('GET unknown assessment returns not_found', async ({ request }) => {
|
||||
const resp = await request.get(
|
||||
`${BASE}/api/vendor-compliance/assessments/00000000-0000-0000-0000-000000000000`,
|
||||
)
|
||||
const data = await resp.json()
|
||||
expect(data.status).toBe('not_found')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cross-Check Scenarios ──────────────────────────────────────────
|
||||
|
||||
test.describe('Vendor Assessment — Cross-Check', () => {
|
||||
test.setTimeout(120000)
|
||||
|
||||
test('single AVV without TOM triggers cross-check finding', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Submit only an AVV (no TOM annex) — should trigger cross-check
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'Cross-Check Test')
|
||||
|
||||
const urlInput = page.locator('input[type="url"]').first()
|
||||
await urlInput.fill('https://www.hetzner.com/de/legal/privacy-policy/')
|
||||
await page.selectOption('select', 'avv')
|
||||
|
||||
await page.click('button:has-text("Pruefung starten")')
|
||||
|
||||
// Wait for result
|
||||
await expect(page.locator('text=Cross-Check Test')).toBeVisible({ timeout: 90000 })
|
||||
|
||||
const body = await page.textContent('body')
|
||||
|
||||
// Cross-check should detect missing TOM annex
|
||||
// (The AVV checklist mentions TOM, but no TOM doc was uploaded)
|
||||
expect(body).toContain('Cross-Check')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-cross-check.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user