From 185d680669080ff47346845a6174b6ed91b23f3e Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 12 May 2026 23:37:45 +0200 Subject: [PATCH] feat(vendor-assessment): E2E tests + remove old use-case-audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../api/sdk/v1/use-case/[[...path]]/route.ts | 91 ----- .../app/sdk/use-case-audit/[auditId]/page.tsx | 247 -------------- .../_components/AuditResult.tsx | 142 -------- .../_components/QuestionnaireView.tsx | 215 ------------ .../_components/UseCaseSelector.tsx | 138 -------- .../app/sdk/use-case-audit/page.tsx | 208 ------------ .../e2e/specs/vendor-assessment.spec.ts | 315 ++++++++++++++++++ 7 files changed, 315 insertions(+), 1041 deletions(-) delete mode 100644 admin-compliance/app/api/sdk/v1/use-case/[[...path]]/route.ts delete mode 100644 admin-compliance/app/sdk/use-case-audit/[auditId]/page.tsx delete mode 100644 admin-compliance/app/sdk/use-case-audit/_components/AuditResult.tsx delete mode 100644 admin-compliance/app/sdk/use-case-audit/_components/QuestionnaireView.tsx delete mode 100644 admin-compliance/app/sdk/use-case-audit/_components/UseCaseSelector.tsx delete mode 100644 admin-compliance/app/sdk/use-case-audit/page.tsx create mode 100644 admin-compliance/e2e/specs/vendor-assessment.spec.ts diff --git a/admin-compliance/app/api/sdk/v1/use-case/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/use-case/[[...path]]/route.ts deleted file mode 100644 index 90d9846..0000000 --- a/admin-compliance/app/api/sdk/v1/use-case/[[...path]]/route.ts +++ /dev/null @@ -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') -} diff --git a/admin-compliance/app/sdk/use-case-audit/[auditId]/page.tsx b/admin-compliance/app/sdk/use-case-audit/[auditId]/page.tsx deleted file mode 100644 index c0356f2..0000000 --- a/admin-compliance/app/sdk/use-case-audit/[auditId]/page.tsx +++ /dev/null @@ -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 - by_severity: Record -} - -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(null) - const [answers, setAnswers] = useState([]) - const [score, setScore] = useState(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 ( -
-
Lade Audit...
-
- ) - } - - if (error || !audit) { - return ( -
-
-
-

{error || 'Audit nicht gefunden'}

- - Zurueck zur Liste - -
-
-
- ) - } - - const showResult = audit.status === 'completed' || score !== null - - return ( -
-
- {/* Header */} -
-
- - ← Alle Audits - -

{audit.name}

- {audit.target_name && ( -

{audit.target_name}

- )} -
-
- {/* Progress indicator */} -
-
- {audit.answered_questions} / {audit.total_questions} beantwortet -
-
-
0 ? (audit.answered_questions / audit.total_questions) * 100 : 0}%` }} - /> -
-
- {audit.answered_questions > 0 && ( - - )} -
-
- - {error && ( -
-

{error}

-
- )} - - {showResult && score ? ( - - ) : ( - - )} -
-
- ) -} diff --git a/admin-compliance/app/sdk/use-case-audit/_components/AuditResult.tsx b/admin-compliance/app/sdk/use-case-audit/_components/AuditResult.tsx deleted file mode 100644 index 8901566..0000000 --- a/admin-compliance/app/sdk/use-case-audit/_components/AuditResult.tsx +++ /dev/null @@ -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 - by_severity: Record -} - -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 ( -
- {/* Score Overview */} -
-
- {Math.round(score.compliance_score)}% -
-
Compliance Score
-
- {audit.name}{audit.target_name ? ` — ${audit.target_name}` : ''} -
-
- - {/* Summary Stats */} -
-
-
{score.answered}
-
Beantwortet
-
-
-
{score.passed}
-
Bestanden
-
-
-
{score.failed}
-
Nicht bestanden
-
-
-
{score.skipped}
-
Uebersprungen
-
-
- - {/* By Regulation */} - {regEntries.length > 0 && ( -
-

Nach Regulierung

-
- {regEntries.map(([reg, data]) => ( -
- {reg} -
-
= 80 ? 'bg-green-500' : - data.score >= 50 ? 'bg-yellow-500' : 'bg-red-500' - }`} - style={{ width: `${data.score}%` }} - /> -
- - {Math.round(data.score)}% - - - {data.passed}/{data.total} - -
- ))} -
-
- )} - - {/* By Severity */} - {sevEntries.length > 0 && ( -
-

Nach Schweregrad

-
- {sevEntries.map(([sev, data]) => ( -
-
- {sev} -
-
{data.passed}/{data.total}
-
- {data.failed} nicht bestanden -
-
- ))} -
-
- )} - - {/* Actions */} - -
- ) -} diff --git a/admin-compliance/app/sdk/use-case-audit/_components/QuestionnaireView.tsx b/admin-compliance/app/sdk/use-case-audit/_components/QuestionnaireView.tsx deleted file mode 100644 index 5de9720..0000000 --- a/admin-compliance/app/sdk/use-case-audit/_components/QuestionnaireView.tsx +++ /dev/null @@ -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 = { - 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>({}) - const [showCriteria, setShowCriteria] = useState(null) - - const answerMap = useMemo(() => { - const m: Record = {} - 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 = {} - for (const q of visibleQuestions) { - const key = q.regulation || 'Allgemein' - if (!groups[key]) groups[key] = [] - groups[key].push(q) - } - return groups - }, [visibleQuestions]) - - return ( -
- {Object.entries(grouped).map(([reg, qs]) => ( -
-

- - {reg} - - - {qs.length} Fragen - -

- -
- {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 ( -
-
- - {q.id} - -
-
-

{q.question}

-
- - {q.severity} - - {q.evidence_required && ( - - Nachweis - - )} -
-
- - {q.mc_name && ( -

- MC: {q.mc_name} {q.mc_id && `(${q.mc_id})`} -

- )} - - {/* Criteria toggle */} - - - {showCriteria === q.id && ( -
- {q.pass_criteria?.length > 0 && ( -
-
Pass
- {q.pass_criteria.map((c, i) => ( -
{c}
- ))} -
- )} - {q.fail_criteria?.length > 0 && ( -
-
Fail
- {q.fail_criteria.map((c, i) => ( -
{c}
- ))} -
- )} -
- )} - - {/* Answer controls */} - {!isAnswered && !isSkipped && ( -
- - - - 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" - /> -
- )} - - {/* Already answered */} - {isAnswered && ( -
- - {isPassed ? 'Ja' : 'Nein'} - - {existing.comment && ( - — {existing.comment} - )} -
- )} - - {isSkipped && ( -
Uebersprungen
- )} -
-
-
- ) - })} -
-
- ))} -
- ) -} diff --git a/admin-compliance/app/sdk/use-case-audit/_components/UseCaseSelector.tsx b/admin-compliance/app/sdk/use-case-audit/_components/UseCaseSelector.tsx deleted file mode 100644 index 1612c60..0000000 --- a/admin-compliance/app/sdk/use-case-audit/_components/UseCaseSelector.tsx +++ /dev/null @@ -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 = { - 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