feat: Alle 5 verbleibenden SDK-Module auf 100% — RAG, Security-Backlog, Quality, Notfallplan, Loeschfristen
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
Paket A — RAG Proxy: - NEU: admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts → Proxy zu ai-compliance-sdk:8090, GET+POST, UUID-Validierung - UPDATE: rag/page.tsx — setTimeout Mock → echte API-Calls GET /regulations → dynamische suggestedQuestions POST /search → Qdrant-Ergebnisse mit score, title, reference Paket B — Security-Backlog + Quality: - NEU: migrations/014_security_backlog.sql + 015_quality.sql - NEU: compliance/api/security_backlog_routes.py — CRUD + Stats - NEU: compliance/api/quality_routes.py — Metrics + Tests CRUD + Stats - UPDATE: security-backlog/page.tsx — mockItems → API - UPDATE: quality/page.tsx — mockMetrics/mockTests → API - UPDATE: compliance/api/__init__.py — Router-Registrierung - NEU: tests/test_security_backlog_routes.py (48 Tests — 48/48 bestanden) - NEU: tests/test_quality_routes.py (67 Tests — 67/67 bestanden) Paket C — Notfallplan Incidents + Templates: - NEU: migrations/016_notfallplan_incidents.sql compliance_notfallplan_incidents + compliance_notfallplan_templates - UPDATE: notfallplan_routes.py — GET/POST/PUT/DELETE für /incidents + /templates - UPDATE: notfallplan/page.tsx — Incidents-Tab + Templates-Tab → API - UPDATE: tests/test_notfallplan_routes.py (+76 neue Tests — alle bestanden) Paket D — Loeschfristen localStorage → API: - NEU: migrations/017_loeschfristen.sql (JSONB: legal_holds, storage_locations, ...) - NEU: compliance/api/loeschfristen_routes.py — CRUD + Stats + Status-Update - UPDATE: loeschfristen/page.tsx — vollständige localStorage → API Migration createNewPolicy → POST (API-UUID als id), deletePolicy → DELETE, handleSaveAndClose → PUT, adoptGeneratedPolicies → POST je Policy apiToPolicy() + policyToPayload() Mapper, saving-State für Buttons - NEU: tests/test_loeschfristen_routes.py (58 Tests — alle bestanden) Gesamt: 253 neue Tests, alle bestanden (48 + 67 + 76 + 58 + bestehende) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,7 @@ import {
|
||||
ReviewInterval, DeletionTriggerLevel, RetentionUnit, LegalHoldStatus,
|
||||
createEmptyPolicy, createEmptyLegalHold, createEmptyStorageLocation,
|
||||
formatRetentionDuration, isPolicyOverdue, getActiveLegalHolds,
|
||||
getEffectiveDeletionTrigger, LOESCHFRISTEN_STORAGE_KEY,
|
||||
generatePolicyId,
|
||||
getEffectiveDeletionTrigger,
|
||||
} from '@/lib/sdk/loeschfristen-types'
|
||||
import { BASELINE_TEMPLATES, templateToPolicy, getTemplateById, getAllTemplateTags } from '@/lib/sdk/loeschfristen-baseline-catalog'
|
||||
import {
|
||||
@@ -35,8 +34,6 @@ import {
|
||||
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export'
|
||||
|
||||
const STORAGE_KEY = 'bp_loeschfristen'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: TagInput
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -127,45 +124,127 @@ export default function LoeschfristenPage() {
|
||||
// ---- Legal Hold management ----
|
||||
const [managingLegalHolds, setManagingLegalHolds] = useState(false)
|
||||
|
||||
// ---- Saving state ----
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// ---- VVT data ----
|
||||
const [vvtActivities, setVvtActivities] = useState<any[]>([])
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Persistence
|
||||
// Persistence (API-backed)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const LOESCHFRISTEN_API = '/api/sdk/v1/compliance/loeschfristen'
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as LoeschfristPolicy[]
|
||||
setPolicies(parsed)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored policies:', e)
|
||||
}
|
||||
}
|
||||
setLoaded(true)
|
||||
loadPolicies()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && policies.length > 0) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(policies))
|
||||
} else if (loaded && policies.length === 0) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}, [policies, loaded])
|
||||
|
||||
// Load VVT activities from localStorage
|
||||
useEffect(() => {
|
||||
async function loadPolicies() {
|
||||
try {
|
||||
const raw = localStorage.getItem('bp_vvt')
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) setVvtActivities(parsed)
|
||||
const res = await fetch(`${LOESCHFRISTEN_API}?limit=500`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const fetched = Array.isArray(data.policies)
|
||||
? data.policies.map(apiToPolicy)
|
||||
: []
|
||||
setPolicies(fetched)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
console.error('Failed to load Loeschfristen from API:', e)
|
||||
}
|
||||
setLoaded(true)
|
||||
}
|
||||
|
||||
function apiToPolicy(raw: any): LoeschfristPolicy {
|
||||
// Map snake_case API response to camelCase LoeschfristPolicy
|
||||
const base = createEmptyPolicy()
|
||||
return {
|
||||
...base,
|
||||
id: raw.id, // DB UUID — used for API calls
|
||||
policyId: raw.policy_id || base.policyId, // Display ID like "LF-2026-001"
|
||||
dataObjectName: raw.data_object_name || '',
|
||||
description: raw.description || '',
|
||||
affectedGroups: raw.affected_groups || [],
|
||||
dataCategories: raw.data_categories || [],
|
||||
primaryPurpose: raw.primary_purpose || '',
|
||||
deletionTrigger: raw.deletion_trigger || 'PURPOSE_END',
|
||||
retentionDriver: raw.retention_driver || null,
|
||||
retentionDriverDetail: raw.retention_driver_detail || '',
|
||||
retentionDuration: raw.retention_duration ?? null,
|
||||
retentionUnit: raw.retention_unit || null,
|
||||
retentionDescription: raw.retention_description || '',
|
||||
startEvent: raw.start_event || '',
|
||||
hasActiveLegalHold: raw.has_active_legal_hold || false,
|
||||
legalHolds: raw.legal_holds || [],
|
||||
storageLocations: raw.storage_locations || [],
|
||||
deletionMethod: raw.deletion_method || 'MANUAL_REVIEW_DELETE',
|
||||
deletionMethodDetail: raw.deletion_method_detail || '',
|
||||
responsibleRole: raw.responsible_role || '',
|
||||
responsiblePerson: raw.responsible_person || '',
|
||||
releaseProcess: raw.release_process || '',
|
||||
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
|
||||
status: raw.status || 'DRAFT',
|
||||
lastReviewDate: raw.last_review_date || base.lastReviewDate,
|
||||
nextReviewDate: raw.next_review_date || base.nextReviewDate,
|
||||
reviewInterval: raw.review_interval || 'ANNUAL',
|
||||
tags: raw.tags || [],
|
||||
createdAt: raw.created_at || base.createdAt,
|
||||
updatedAt: raw.updated_at || base.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function policyToPayload(p: LoeschfristPolicy): any {
|
||||
return {
|
||||
policy_id: p.policyId,
|
||||
data_object_name: p.dataObjectName,
|
||||
description: p.description,
|
||||
affected_groups: p.affectedGroups,
|
||||
data_categories: p.dataCategories,
|
||||
primary_purpose: p.primaryPurpose,
|
||||
deletion_trigger: p.deletionTrigger,
|
||||
retention_driver: p.retentionDriver || null,
|
||||
retention_driver_detail: p.retentionDriverDetail,
|
||||
retention_duration: p.retentionDuration || null,
|
||||
retention_unit: p.retentionUnit || null,
|
||||
retention_description: p.retentionDescription,
|
||||
start_event: p.startEvent,
|
||||
has_active_legal_hold: p.hasActiveLegalHold,
|
||||
legal_holds: p.legalHolds,
|
||||
storage_locations: p.storageLocations,
|
||||
deletion_method: p.deletionMethod,
|
||||
deletion_method_detail: p.deletionMethodDetail,
|
||||
responsible_role: p.responsibleRole,
|
||||
responsible_person: p.responsiblePerson,
|
||||
release_process: p.releaseProcess,
|
||||
linked_vvt_activity_ids: p.linkedVVTActivityIds,
|
||||
status: p.status,
|
||||
last_review_date: p.lastReviewDate || null,
|
||||
next_review_date: p.nextReviewDate || null,
|
||||
review_interval: p.reviewInterval,
|
||||
tags: p.tags,
|
||||
}
|
||||
}
|
||||
|
||||
// Load VVT activities from API
|
||||
useEffect(() => {
|
||||
fetch('/api/sdk/v1/compliance/vvt?limit=200')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (data && Array.isArray(data.activities)) {
|
||||
setVvtActivities(data.activities)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback: try localStorage
|
||||
try {
|
||||
const raw = localStorage.getItem('bp_vvt')
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) setVvtActivities(parsed)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
}, [tab, editingId])
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -222,19 +301,45 @@ export default function LoeschfristenPage() {
|
||||
[],
|
||||
)
|
||||
|
||||
const createNewPolicy = useCallback(() => {
|
||||
const newP = createEmptyPolicy()
|
||||
setPolicies((prev) => [...prev, newP])
|
||||
setEditingId(newP.policyId)
|
||||
setTab('editor')
|
||||
const createNewPolicy = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const empty = createEmptyPolicy()
|
||||
const res = await fetch(LOESCHFRISTEN_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policyToPayload(empty)),
|
||||
})
|
||||
if (res.ok) {
|
||||
const raw = await res.json()
|
||||
const newP = apiToPolicy(raw)
|
||||
setPolicies((prev) => [...prev, newP])
|
||||
setEditingId(newP.policyId)
|
||||
setTab('editor')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create policy:', e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const deletePolicy = useCallback(
|
||||
(id: string) => {
|
||||
setPolicies((prev) => prev.filter((p) => p.policyId !== id))
|
||||
if (editingId === id) setEditingId(null)
|
||||
async (policyId: string) => {
|
||||
const policy = policies.find((p) => p.policyId === policyId)
|
||||
if (!policy) return
|
||||
try {
|
||||
const res = await fetch(`${LOESCHFRISTEN_API}/${policy.id}`, { method: 'DELETE' })
|
||||
if (res.ok || res.status === 204 || res.status === 404) {
|
||||
setPolicies((prev) => prev.filter((p) => p.policyId !== policyId))
|
||||
if (editingId === policyId) setEditingId(null)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete policy:', e)
|
||||
}
|
||||
},
|
||||
[editingId],
|
||||
[editingId, policies],
|
||||
)
|
||||
|
||||
const addLegalHold = useCallback(
|
||||
@@ -302,17 +407,41 @@ export default function LoeschfristenPage() {
|
||||
}, [profilingAnswers])
|
||||
|
||||
const adoptGeneratedPolicies = useCallback(
|
||||
(onlySelected: boolean) => {
|
||||
async (onlySelected: boolean) => {
|
||||
const toAdopt = onlySelected
|
||||
? generatedPolicies.filter((p) => selectedGenerated.has(p.policyId))
|
||||
: generatedPolicies
|
||||
setPolicies((prev) => [...prev, ...toAdopt])
|
||||
setSaving(true)
|
||||
try {
|
||||
const created: LoeschfristPolicy[] = []
|
||||
for (const p of toAdopt) {
|
||||
try {
|
||||
const res = await fetch(LOESCHFRISTEN_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policyToPayload(p)),
|
||||
})
|
||||
if (res.ok) {
|
||||
const raw = await res.json()
|
||||
created.push(apiToPolicy(raw))
|
||||
} else {
|
||||
created.push(p) // fallback: keep generated policy in state
|
||||
}
|
||||
} catch {
|
||||
created.push(p)
|
||||
}
|
||||
}
|
||||
setPolicies((prev) => [...prev, ...created])
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
setGeneratedPolicies([])
|
||||
setSelectedGenerated(new Set())
|
||||
setProfilingStep(0)
|
||||
setProfilingAnswers([])
|
||||
setTab('uebersicht')
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[generatedPolicies, selectedGenerated],
|
||||
)
|
||||
|
||||
@@ -321,6 +450,36 @@ export default function LoeschfristenPage() {
|
||||
setComplianceResult(result)
|
||||
}, [policies])
|
||||
|
||||
const handleSaveAndClose = useCallback(async () => {
|
||||
if (!editingPolicy) {
|
||||
setEditingId(null)
|
||||
setTab('uebersicht')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${LOESCHFRISTEN_API}/${editingPolicy.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policyToPayload(editingPolicy)),
|
||||
})
|
||||
if (res.ok) {
|
||||
const raw = await res.json()
|
||||
const updated = apiToPolicy(raw)
|
||||
setPolicies((prev) =>
|
||||
prev.map((p) => (p.policyId === editingPolicy.policyId ? updated : p)),
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save policy:', e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
setEditingId(null)
|
||||
setTab('uebersicht')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editingPolicy])
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Tab definitions
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -1385,13 +1544,11 @@ export default function LoeschfristenPage() {
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(null)
|
||||
setTab('uebersicht')
|
||||
}}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition"
|
||||
onClick={handleSaveAndClose}
|
||||
disabled={saving}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 rounded-lg px-4 py-2 font-medium transition"
|
||||
>
|
||||
Speichern & Schliessen
|
||||
{saving ? 'Speichern...' : 'Speichern & Schliessen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -387,10 +387,9 @@ export default function NotfallplanPage() {
|
||||
const [savingExercise, setSavingExercise] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Only load config and exercises from localStorage (incidents/templates use API)
|
||||
const data = loadFromStorage()
|
||||
setConfig(data.config)
|
||||
setIncidents(data.incidents)
|
||||
setTemplates(data.templates)
|
||||
setExercises(data.exercises)
|
||||
}, [])
|
||||
|
||||
@@ -401,10 +400,12 @@ export default function NotfallplanPage() {
|
||||
async function loadApiData() {
|
||||
setApiLoading(true)
|
||||
try {
|
||||
const [contactsRes, scenariosRes, exercisesRes] = await Promise.all([
|
||||
const [contactsRes, scenariosRes, exercisesRes, incidentsRes, templatesRes] = await Promise.all([
|
||||
fetch(`${NOTFALLPLAN_API}/contacts`),
|
||||
fetch(`${NOTFALLPLAN_API}/scenarios`),
|
||||
fetch(`${NOTFALLPLAN_API}/exercises`),
|
||||
fetch(`${NOTFALLPLAN_API}/incidents`),
|
||||
fetch(`${NOTFALLPLAN_API}/templates`),
|
||||
])
|
||||
if (contactsRes.ok) {
|
||||
const data = await contactsRes.json()
|
||||
@@ -418,6 +419,14 @@ export default function NotfallplanPage() {
|
||||
const data = await exercisesRes.json()
|
||||
setApiExercises(Array.isArray(data) ? data : [])
|
||||
}
|
||||
if (incidentsRes.ok) {
|
||||
const data = await incidentsRes.json()
|
||||
setIncidents(Array.isArray(data) ? data.map(apiToIncident) : [])
|
||||
}
|
||||
if (templatesRes.ok) {
|
||||
const data = await templatesRes.json()
|
||||
setTemplates(Array.isArray(data) && data.length > 0 ? data : DEFAULT_TEMPLATES)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load Notfallplan API data:', err)
|
||||
} finally {
|
||||
@@ -425,6 +434,106 @@ export default function NotfallplanPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function apiToIncident(raw: any): Incident {
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
description: raw.description || '',
|
||||
detectedAt: raw.detected_at,
|
||||
detectedBy: raw.detected_by || 'Admin',
|
||||
status: raw.status,
|
||||
severity: raw.severity,
|
||||
affectedDataCategories: raw.affected_data_categories || [],
|
||||
estimatedAffectedPersons: raw.estimated_affected_persons || 0,
|
||||
measures: raw.measures || [],
|
||||
art34Required: raw.art34_required || false,
|
||||
art34Justification: raw.art34_justification || '',
|
||||
reportedToAuthorityAt: raw.reported_to_authority_at,
|
||||
notifiedAffectedAt: raw.notified_affected_at,
|
||||
closedAt: raw.closed_at,
|
||||
closedBy: raw.closed_by,
|
||||
lessonsLearned: raw.lessons_learned,
|
||||
}
|
||||
}
|
||||
|
||||
async function apiAddIncident(newIncident: Partial<Incident>) {
|
||||
try {
|
||||
const res = await fetch(`${NOTFALLPLAN_API}/incidents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: newIncident.title,
|
||||
description: newIncident.description,
|
||||
severity: newIncident.severity || 'medium',
|
||||
estimated_affected_persons: newIncident.estimatedAffectedPersons || 0,
|
||||
art34_required: newIncident.art34Required || false,
|
||||
art34_justification: newIncident.art34Justification || '',
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setIncidents(prev => [apiToIncident(created), ...prev])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create incident:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function apiUpdateIncidentStatus(id: string, status: IncidentStatus) {
|
||||
try {
|
||||
const body: any = { status }
|
||||
if (status === 'reported') body.reported_to_authority_at = new Date().toISOString()
|
||||
if (status === 'closed') { body.closed_at = new Date().toISOString(); body.closed_by = 'Admin' }
|
||||
const res = await fetch(`${NOTFALLPLAN_API}/incidents/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setIncidents(prev => prev.map(inc => inc.id === id ? apiToIncident(updated) : inc))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update incident status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function apiDeleteIncident(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${NOTFALLPLAN_API}/incidents/${id}`, { method: 'DELETE' })
|
||||
if (res.ok || res.status === 204) {
|
||||
setIncidents(prev => prev.filter(inc => inc.id !== id))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete incident:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function apiSaveTemplate(template: MeldeTemplate) {
|
||||
try {
|
||||
const isNew = !template.id.startsWith('tpl-')
|
||||
const method = isNew || template.id.length < 10 ? 'POST' : 'PUT'
|
||||
const url = method === 'POST'
|
||||
? `${NOTFALLPLAN_API}/templates`
|
||||
: `${NOTFALLPLAN_API}/templates/${template.id}`
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: template.type, title: template.title, content: template.content }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const saved = await res.json()
|
||||
setTemplates(prev => {
|
||||
const existing = prev.find(t => t.id === template.id)
|
||||
if (existing) return prev.map(t => t.id === template.id ? { ...t, ...saved } : t)
|
||||
return [...prev, saved]
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save template:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateContact() {
|
||||
if (!newContact.name) return
|
||||
setSavingContact(true)
|
||||
@@ -529,10 +638,11 @@ export default function NotfallplanPage() {
|
||||
}
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
saveToStorage({ config, incidents, templates, exercises })
|
||||
// Only config and exercises are persisted to localStorage; incidents/templates use API
|
||||
saveToStorage({ config, incidents: [], templates: DEFAULT_TEMPLATES, exercises })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}, [config, incidents, templates, exercises])
|
||||
}, [config, exercises])
|
||||
|
||||
const tabs: { id: Tab; label: string; count?: number }[] = [
|
||||
{ id: 'config', label: 'Notfallplan' },
|
||||
@@ -697,10 +807,13 @@ export default function NotfallplanPage() {
|
||||
setIncidents={setIncidents}
|
||||
showAdd={showAddIncident}
|
||||
setShowAdd={setShowAddIncident}
|
||||
onAdd={apiAddIncident}
|
||||
onStatusChange={apiUpdateIncidentStatus}
|
||||
onDelete={apiDeleteIncident}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'templates' && (
|
||||
<TemplatesTab templates={templates} setTemplates={setTemplates} />
|
||||
<TemplatesTab templates={templates} setTemplates={setTemplates} onSave={apiSaveTemplate} />
|
||||
)}
|
||||
{activeTab === 'exercises' && (
|
||||
<>
|
||||
@@ -1252,11 +1365,17 @@ function IncidentsTab({
|
||||
setIncidents,
|
||||
showAdd,
|
||||
setShowAdd,
|
||||
onAdd,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
}: {
|
||||
incidents: Incident[]
|
||||
setIncidents: React.Dispatch<React.SetStateAction<Incident[]>>
|
||||
showAdd: boolean
|
||||
setShowAdd: (v: boolean) => void
|
||||
onAdd?: (incident: Partial<Incident>) => Promise<void>
|
||||
onStatusChange?: (id: string, status: IncidentStatus) => Promise<void>
|
||||
onDelete?: (id: string) => Promise<void>
|
||||
}) {
|
||||
const [newIncident, setNewIncident] = useState<Partial<Incident>>({
|
||||
title: '',
|
||||
@@ -1269,23 +1388,27 @@ function IncidentsTab({
|
||||
art34Justification: '',
|
||||
})
|
||||
|
||||
function addIncident() {
|
||||
async function addIncident() {
|
||||
if (!newIncident.title) return
|
||||
const incident: Incident = {
|
||||
id: `INC-${Date.now()}`,
|
||||
title: newIncident.title || '',
|
||||
description: newIncident.description || '',
|
||||
detectedAt: new Date().toISOString(),
|
||||
detectedBy: 'Admin',
|
||||
status: 'detected',
|
||||
severity: newIncident.severity as IncidentSeverity || 'medium',
|
||||
affectedDataCategories: newIncident.affectedDataCategories || [],
|
||||
estimatedAffectedPersons: newIncident.estimatedAffectedPersons || 0,
|
||||
measures: newIncident.measures || [],
|
||||
art34Required: newIncident.art34Required || false,
|
||||
art34Justification: newIncident.art34Justification || '',
|
||||
if (onAdd) {
|
||||
await onAdd(newIncident)
|
||||
} else {
|
||||
const incident: Incident = {
|
||||
id: `INC-${Date.now()}`,
|
||||
title: newIncident.title || '',
|
||||
description: newIncident.description || '',
|
||||
detectedAt: new Date().toISOString(),
|
||||
detectedBy: 'Admin',
|
||||
status: 'detected',
|
||||
severity: newIncident.severity as IncidentSeverity || 'medium',
|
||||
affectedDataCategories: newIncident.affectedDataCategories || [],
|
||||
estimatedAffectedPersons: newIncident.estimatedAffectedPersons || 0,
|
||||
measures: newIncident.measures || [],
|
||||
art34Required: newIncident.art34Required || false,
|
||||
art34Justification: newIncident.art34Justification || '',
|
||||
}
|
||||
setIncidents(prev => [incident, ...prev])
|
||||
}
|
||||
setIncidents(prev => [incident, ...prev])
|
||||
setShowAdd(false)
|
||||
setNewIncident({
|
||||
title: '', description: '', severity: 'medium',
|
||||
@@ -1294,17 +1417,21 @@ function IncidentsTab({
|
||||
})
|
||||
}
|
||||
|
||||
function updateStatus(id: string, status: IncidentStatus) {
|
||||
setIncidents(prev => prev.map(inc =>
|
||||
inc.id === id
|
||||
? {
|
||||
...inc,
|
||||
status,
|
||||
...(status === 'reported' ? { reportedToAuthorityAt: new Date().toISOString() } : {}),
|
||||
...(status === 'closed' ? { closedAt: new Date().toISOString(), closedBy: 'Admin' } : {}),
|
||||
}
|
||||
: inc
|
||||
))
|
||||
async function updateStatus(id: string, status: IncidentStatus) {
|
||||
if (onStatusChange) {
|
||||
await onStatusChange(id, status)
|
||||
} else {
|
||||
setIncidents(prev => prev.map(inc =>
|
||||
inc.id === id
|
||||
? {
|
||||
...inc,
|
||||
status,
|
||||
...(status === 'reported' ? { reportedToAuthorityAt: new Date().toISOString() } : {}),
|
||||
...(status === 'closed' ? { closedAt: new Date().toISOString(), closedBy: 'Admin' } : {}),
|
||||
}
|
||||
: inc
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1464,6 +1591,14 @@ function IncidentsTab({
|
||||
Abschliessen
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => { if (window.confirm('Incident loeschen?')) onDelete(incident.id) }}
|
||||
className="text-xs px-2 py-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded ml-auto"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1481,10 +1616,24 @@ function IncidentsTab({
|
||||
function TemplatesTab({
|
||||
templates,
|
||||
setTemplates,
|
||||
onSave,
|
||||
}: {
|
||||
templates: MeldeTemplate[]
|
||||
setTemplates: React.Dispatch<React.SetStateAction<MeldeTemplate[]>>
|
||||
onSave?: (template: MeldeTemplate) => Promise<void>
|
||||
}) {
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
async function handleSave(template: MeldeTemplate) {
|
||||
if (!onSave) return
|
||||
setSaving(template.id)
|
||||
try {
|
||||
await onSave(template)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -1496,15 +1645,26 @@ function TemplatesTab({
|
||||
|
||||
{templates.map(template => (
|
||||
<div key={template.id} className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold ${
|
||||
template.type === 'art33'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{template.type === 'art33' ? 'Art. 33' : 'Art. 34'}
|
||||
</span>
|
||||
<h4 className="font-medium">{template.title}</h4>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold ${
|
||||
template.type === 'art33'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{template.type === 'art33' ? 'Art. 33' : 'Art. 34'}
|
||||
</span>
|
||||
<h4 className="font-medium">{template.title}</h4>
|
||||
</div>
|
||||
{onSave && (
|
||||
<button
|
||||
onClick={() => handleSave(template)}
|
||||
disabled={saving === template.id}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving === template.id ? 'Gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={template.content}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
@@ -14,140 +14,35 @@ interface QualityMetric {
|
||||
score: number
|
||||
threshold: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
lastMeasured: Date
|
||||
aiSystem: string
|
||||
last_measured: string
|
||||
ai_system: string | null
|
||||
}
|
||||
|
||||
interface QualityTest {
|
||||
id: string
|
||||
name: string
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending'
|
||||
lastRun: Date
|
||||
duration: string
|
||||
aiSystem: string
|
||||
details: string
|
||||
last_run: string
|
||||
duration: string | null
|
||||
ai_system: string | null
|
||||
details: string | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockMetrics: QualityMetric[] = [
|
||||
{
|
||||
id: 'm-1',
|
||||
name: 'Accuracy Score',
|
||||
category: 'accuracy',
|
||||
score: 94.5,
|
||||
threshold: 90,
|
||||
trend: 'up',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
},
|
||||
{
|
||||
id: 'm-2',
|
||||
name: 'Fairness Index (Gender)',
|
||||
category: 'fairness',
|
||||
score: 87.2,
|
||||
threshold: 85,
|
||||
trend: 'stable',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
},
|
||||
{
|
||||
id: 'm-3',
|
||||
name: 'Fairness Index (Age)',
|
||||
category: 'fairness',
|
||||
score: 78.5,
|
||||
threshold: 85,
|
||||
trend: 'down',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
},
|
||||
{
|
||||
id: 'm-4',
|
||||
name: 'Robustness Score',
|
||||
category: 'robustness',
|
||||
score: 91.0,
|
||||
threshold: 85,
|
||||
trend: 'up',
|
||||
lastMeasured: new Date('2024-01-21'),
|
||||
aiSystem: 'Kundenservice Chatbot',
|
||||
},
|
||||
{
|
||||
id: 'm-5',
|
||||
name: 'Explainability Index',
|
||||
category: 'explainability',
|
||||
score: 72.3,
|
||||
threshold: 75,
|
||||
trend: 'up',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Empfehlungsalgorithmus',
|
||||
},
|
||||
{
|
||||
id: 'm-6',
|
||||
name: 'Response Time (P95)',
|
||||
category: 'performance',
|
||||
score: 95.0,
|
||||
threshold: 90,
|
||||
trend: 'stable',
|
||||
lastMeasured: new Date('2024-01-22'),
|
||||
aiSystem: 'Kundenservice Chatbot',
|
||||
},
|
||||
]
|
||||
|
||||
const mockTests: QualityTest[] = [
|
||||
{
|
||||
id: 't-1',
|
||||
name: 'Bias Detection Test',
|
||||
status: 'warning',
|
||||
lastRun: new Date('2024-01-22T10:30:00'),
|
||||
duration: '45min',
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
details: 'Leichte Verzerrung bei Altersgruppe 50+ erkannt',
|
||||
},
|
||||
{
|
||||
id: 't-2',
|
||||
name: 'Accuracy Benchmark',
|
||||
status: 'passed',
|
||||
lastRun: new Date('2024-01-22T08:00:00'),
|
||||
duration: '2h 15min',
|
||||
aiSystem: 'Bewerber-Screening',
|
||||
details: 'Alle Schwellenwerte eingehalten',
|
||||
},
|
||||
{
|
||||
id: 't-3',
|
||||
name: 'Adversarial Testing',
|
||||
status: 'passed',
|
||||
lastRun: new Date('2024-01-21T14:00:00'),
|
||||
duration: '1h 30min',
|
||||
aiSystem: 'Kundenservice Chatbot',
|
||||
details: 'System robust gegen Manipulation',
|
||||
},
|
||||
{
|
||||
id: 't-4',
|
||||
name: 'Explainability Test',
|
||||
status: 'failed',
|
||||
lastRun: new Date('2024-01-22T09:00:00'),
|
||||
duration: '30min',
|
||||
aiSystem: 'Empfehlungsalgorithmus',
|
||||
details: 'SHAP-Werte unter Schwellenwert',
|
||||
},
|
||||
{
|
||||
id: 't-5',
|
||||
name: 'Performance Load Test',
|
||||
status: 'passed',
|
||||
lastRun: new Date('2024-01-22T06:00:00'),
|
||||
duration: '3h',
|
||||
aiSystem: 'Kundenservice Chatbot',
|
||||
details: '10.000 gleichzeitige Anfragen verarbeitet',
|
||||
},
|
||||
]
|
||||
interface Stats {
|
||||
total_metrics: number
|
||||
avg_score: number
|
||||
metrics_above_threshold: number
|
||||
passed: number
|
||||
failed: number
|
||||
warning: number
|
||||
total_tests: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function MetricCard({ metric }: { metric: QualityMetric }) {
|
||||
function MetricCard({ metric, onEdit }: { metric: QualityMetric; onEdit: (m: QualityMetric) => void }) {
|
||||
const isAboveThreshold = metric.score >= metric.threshold
|
||||
const categoryColors = {
|
||||
accuracy: 'bg-blue-100 text-blue-700',
|
||||
@@ -166,36 +61,22 @@ function MetricCard({ metric }: { metric: QualityMetric }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
isAboveThreshold ? 'border-gray-200' : 'border-red-200'
|
||||
}`}>
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${isAboveThreshold ? 'border-gray-200' : 'border-red-200'}`}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[metric.category]}`}>
|
||||
{categoryLabels[metric.category]}
|
||||
</span>
|
||||
<h4 className="font-semibold text-gray-900 mt-2">{metric.name}</h4>
|
||||
<p className="text-xs text-gray-500">{metric.aiSystem}</p>
|
||||
{metric.ai_system && <p className="text-xs text-gray-500">{metric.ai_system}</p>}
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-sm ${
|
||||
metric.trend === 'up' ? 'text-green-600' :
|
||||
metric.trend === 'down' ? 'text-red-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{metric.trend === 'up' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
)}
|
||||
{metric.trend === 'down' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
)}
|
||||
{metric.trend === 'stable' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
)}
|
||||
{metric.trend === 'up' && <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" /></svg>}
|
||||
{metric.trend === 'down' && <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" /></svg>}
|
||||
{metric.trend === 'stable' && <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" /></svg>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,22 +85,25 @@ function MetricCard({ metric }: { metric: QualityMetric }) {
|
||||
<div className={`text-3xl font-bold ${isAboveThreshold ? 'text-gray-900' : 'text-red-600'}`}>
|
||||
{metric.score}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Schwellenwert: {metric.threshold}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Schwellenwert: {metric.threshold}%</div>
|
||||
</div>
|
||||
<div className="w-24 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isAboveThreshold ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${metric.score}%` }}
|
||||
style={{ width: `${Math.min(metric.score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button onClick={() => onEdit(metric)} className="text-xs text-purple-600 hover:text-purple-700 hover:bg-purple-50 px-2 py-1 rounded">
|
||||
Score aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestRow({ test }: { test: QualityTest }) {
|
||||
function TestRow({ test, onDelete }: { test: QualityTest; onDelete: (id: string) => void }) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 text-green-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
@@ -238,7 +122,7 @@ function TestRow({ test }: { test: QualityTest }) {
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">{test.name}</div>
|
||||
<div className="text-xs text-gray-500">{test.aiSystem}</div>
|
||||
{test.ai_system && <div className="text-xs text-gray-500">{test.ai_system}</div>}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[test.status]}`}>
|
||||
@@ -246,30 +130,266 @@ function TestRow({ test }: { test: QualityTest }) {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{test.lastRun.toLocaleString('de-DE')}
|
||||
{new Date(test.last_run).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{test.duration}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">{test.details}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{test.duration || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">{test.details || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button className="text-sm text-purple-600 hover:text-purple-700">Details</button>
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`"${test.name}" loeschen?`)) onDelete(test.id) }}
|
||||
className="text-sm text-red-400 hover:text-red-600"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODALS
|
||||
// =============================================================================
|
||||
|
||||
function MetricModal({ metric, onClose, onSave }: {
|
||||
metric?: QualityMetric
|
||||
onClose: () => void
|
||||
onSave: (data: any) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
name: metric?.name || '',
|
||||
category: metric?.category || 'accuracy',
|
||||
score: metric?.score ?? 0,
|
||||
threshold: metric?.threshold ?? 80,
|
||||
trend: metric?.trend || 'stable',
|
||||
ai_system: metric?.ai_system || '',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">{metric ? 'Metrik bearbeiten' : 'Messung hinzufuegen'}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Accuracy Score" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select value={form.category} onChange={e => setForm(p => ({ ...p, category: e.target.value as any }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="accuracy">Genauigkeit</option>
|
||||
<option value="fairness">Fairness</option>
|
||||
<option value="robustness">Robustheit</option>
|
||||
<option value="explainability">Erklaerbarkeit</option>
|
||||
<option value="performance">Performance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Trend</label>
|
||||
<select value={form.trend} onChange={e => setForm(p => ({ ...p, trend: e.target.value as any }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="up">Steigend</option>
|
||||
<option value="stable">Stabil</option>
|
||||
<option value="down">Fallend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Score (%)</label>
|
||||
<input type="number" step="0.1" min="0" max="100" value={form.score} onChange={e => setForm(p => ({ ...p, score: parseFloat(e.target.value) || 0 }))} className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schwellenwert (%)</label>
|
||||
<input type="number" step="0.1" min="0" max="100" value={form.threshold} onChange={e => setForm(p => ({ ...p, threshold: parseFloat(e.target.value) || 80 }))} className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">KI-System</label>
|
||||
<input type="text" value={form.ai_system} onChange={e => setForm(p => ({ ...p, ai_system: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bewerber-Screening" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} disabled={!form.name} className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-50">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TestModal({ onClose, onSave }: { onClose: () => void; onSave: (data: any) => void }) {
|
||||
const [form, setForm] = useState({ name: '', status: 'pending', duration: '', ai_system: '', details: '' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">Test hinzufuegen</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bias Detection Test" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select value={form.status} onChange={e => setForm(p => ({ ...p, status: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="passed">Bestanden</option>
|
||||
<option value="failed">Fehlgeschlagen</option>
|
||||
<option value="warning">Warnung</option>
|
||||
<option value="pending">Ausstehend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer</label>
|
||||
<input type="text" value={form.duration} onChange={e => setForm(p => ({ ...p, duration: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. 45min" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">KI-System</label>
|
||||
<input type="text" value={form.ai_system} onChange={e => setForm(p => ({ ...p, ai_system: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="z.B. Bewerber-Screening" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Details</label>
|
||||
<input type="text" value={form.details} onChange={e => setForm(p => ({ ...p, details: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm" placeholder="Ergebnis-Zusammenfassung" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button onClick={() => onSave(form)} disabled={!form.name} className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-50">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance/quality'
|
||||
|
||||
export default function QualityPage() {
|
||||
const { state } = useSDK()
|
||||
const [metrics] = useState<QualityMetric[]>(mockMetrics)
|
||||
const [tests] = useState<QualityTest[]>(mockTests)
|
||||
const [metrics, setMetrics] = useState<QualityMetric[]>([])
|
||||
const [tests, setTests] = useState<QualityTest[]>([])
|
||||
const [apiStats, setApiStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showMetricModal, setShowMetricModal] = useState(false)
|
||||
const [showTestModal, setShowTestModal] = useState(false)
|
||||
const [editMetric, setEditMetric] = useState<QualityMetric | undefined>(undefined)
|
||||
|
||||
const passedTests = tests.filter(t => t.status === 'passed').length
|
||||
const failedTests = tests.filter(t => t.status === 'failed').length
|
||||
const metricsAboveThreshold = metrics.filter(m => m.score >= m.threshold).length
|
||||
const avgScore = Math.round(metrics.reduce((sum, m) => sum + m.score, 0) / metrics.length)
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [])
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metricsRes, testsRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/metrics?limit=100`),
|
||||
fetch(`${API_BASE}/tests?limit=100`),
|
||||
fetch(`${API_BASE}/stats`),
|
||||
])
|
||||
if (metricsRes.ok) {
|
||||
const d = await metricsRes.json()
|
||||
setMetrics(Array.isArray(d.metrics) ? d.metrics : [])
|
||||
}
|
||||
if (testsRes.ok) {
|
||||
const d = await testsRes.json()
|
||||
setTests(Array.isArray(d.tests) ? d.tests : [])
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
setApiStats(await statsRes.json())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load quality data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateMetric(form: any) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/metrics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setMetrics(prev => [...prev, created])
|
||||
setShowMetricModal(false)
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create metric:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateMetric(form: any) {
|
||||
if (!editMetric) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/metrics/${editMetric.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setMetrics(prev => prev.map(m => m.id === updated.id ? updated : m))
|
||||
setEditMetric(undefined)
|
||||
setShowMetricModal(false)
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update metric:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateTest(form: any) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setTests(prev => [created, ...prev])
|
||||
setShowTestModal(false)
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create test:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTest(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tests/${id}`, { method: 'DELETE' })
|
||||
if (res.ok || res.status === 204) {
|
||||
setTests(prev => prev.filter(t => t.id !== id))
|
||||
loadAll()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete test:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Derived stats — prefer API stats, fallback to computed
|
||||
const avgScore = apiStats ? apiStats.avg_score : (metrics.length > 0 ? Math.round(metrics.reduce((s, m) => s + m.score, 0) / metrics.length) : 0)
|
||||
const metricsAboveThreshold = apiStats ? apiStats.metrics_above_threshold : metrics.filter(m => m.score >= m.threshold).length
|
||||
const passedTests = apiStats ? apiStats.passed : tests.filter(t => t.status === 'passed').length
|
||||
const failedTests = apiStats ? apiStats.failed : tests.filter(t => t.status === 'failed').length
|
||||
const failingMetrics = metrics.filter(m => m.score < m.threshold)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -277,16 +397,24 @@ export default function QualityPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Quality Dashboard</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Ueberwachen Sie die Qualitaet und Fairness Ihrer KI-Systeme
|
||||
</p>
|
||||
<p className="mt-1 text-gray-500">Ueberwachen Sie die Qualitaet und Fairness Ihrer KI-Systeme</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowTestModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
Test hinzufuegen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditMetric(undefined); setShowMetricModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
|
||||
Messung hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Tests ausfuehren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -310,20 +438,14 @@ export default function QualityPage() {
|
||||
</div>
|
||||
|
||||
{/* Alert for failed metrics */}
|
||||
{metrics.filter(m => m.score < m.threshold).length > 0 && (
|
||||
{failingMetrics.length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<svg className="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-800">
|
||||
{metrics.filter(m => m.score < m.threshold).length} Metrik(en) unter Schwellenwert
|
||||
</h4>
|
||||
<p className="text-sm text-yellow-600">
|
||||
Ueberpruefen Sie die betroffenen KI-Systeme und ergreifen Sie Korrekturmassnahmen.
|
||||
</p>
|
||||
<h4 className="font-medium text-yellow-800">{failingMetrics.length} Metrik(en) unter Schwellenwert</h4>
|
||||
<p className="text-sm text-yellow-600">Ueberpruefen Sie die betroffenen KI-Systeme und ergreifen Sie Korrekturmassnahmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -331,11 +453,23 @@ export default function QualityPage() {
|
||||
{/* Metrics Grid */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Qualitaetsmetriken</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{metrics.map(metric => (
|
||||
<MetricCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-400">Lade Metriken...</div>
|
||||
) : metrics.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
|
||||
Noch keine Metriken erfasst. Klicken Sie auf "Messung hinzufuegen".
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{metrics.map(metric => (
|
||||
<MetricCard
|
||||
key={metric.id}
|
||||
metric={metric}
|
||||
onEdit={m => { setEditMetric(m); setShowMetricModal(true) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tests Table */}
|
||||
@@ -355,14 +489,32 @@ export default function QualityPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{tests.map(test => (
|
||||
<TestRow key={test.id} test={test} />
|
||||
{tests.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-400">
|
||||
Noch keine Tests erfasst.
|
||||
</td>
|
||||
</tr>
|
||||
) : tests.map(test => (
|
||||
<TestRow key={test.id} test={test} onDelete={handleDeleteTest} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showMetricModal && (
|
||||
<MetricModal
|
||||
metric={editMetric}
|
||||
onClose={() => { setShowMetricModal(false); setEditMetric(undefined) }}
|
||||
onSave={editMetric ? handleUpdateMetric : handleCreateMetric}
|
||||
/>
|
||||
)}
|
||||
{showTestModal && (
|
||||
<TestModal onClose={() => setShowTestModal(false)} onSave={handleCreateTest} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
@@ -21,47 +21,11 @@ interface Source {
|
||||
relevance: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockHistory: ChatMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Wann ist eine Datenschutz-Folgenabschaetzung nach DSGVO erforderlich?',
|
||||
timestamp: new Date('2024-01-22T10:30:00'),
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
content: `Nach Art. 35 DSGVO ist eine Datenschutz-Folgenabschaetzung (DSFA) erforderlich, wenn eine Verarbeitung voraussichtlich ein hohes Risiko fuer die Rechte und Freiheiten natuerlicher Personen zur Folge hat.
|
||||
|
||||
Dies ist insbesondere der Fall bei:
|
||||
|
||||
1. **Systematischer und umfassender Bewertung** persoenlicher Aspekte natuerlicher Personen, einschliesslich Profiling, auf deren Grundlage Entscheidungen getroffen werden
|
||||
|
||||
2. **Umfangreicher Verarbeitung besonderer Kategorien** personenbezogener Daten (Art. 9) oder von Daten ueber strafrechtliche Verurteilungen (Art. 10)
|
||||
|
||||
3. **Systematischer umfangreicher Ueberwachung** oeffentlich zugaenglicher Bereiche
|
||||
|
||||
Die Aufsichtsbehoerden haben zudem sogenannte "Blacklists" veroeffentlicht, die weitere Verarbeitungstaetigkeiten benennen, fuer die eine DSFA durchzufuehren ist.`,
|
||||
sources: [
|
||||
{ title: 'Art. 35 DSGVO', reference: 'DSGVO Art. 35 Abs. 1, 3', relevance: 0.95 },
|
||||
{ title: 'Erwaegungsgrund 91', reference: 'DSGVO EG 91', relevance: 0.85 },
|
||||
{ title: 'DSFA-Blacklist DSK', reference: 'DSK Beschluss 2018', relevance: 0.75 },
|
||||
],
|
||||
timestamp: new Date('2024-01-22T10:30:05'),
|
||||
},
|
||||
]
|
||||
|
||||
const suggestedQuestions = [
|
||||
'Was sind die Rechte der Betroffenen nach DSGVO?',
|
||||
'Wie lange betraegt die Meldefrist bei einer Datenpanne?',
|
||||
'Welche Anforderungen stellt der AI Act an Hochrisiko-KI?',
|
||||
'Wann brauche ich einen Auftragsverarbeitungsvertrag?',
|
||||
'Was muss in einer Datenschutzerklaerung stehen?',
|
||||
]
|
||||
interface Regulation {
|
||||
name: string
|
||||
description?: string
|
||||
collection?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
@@ -98,6 +62,9 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{source.title}
|
||||
{source.relevance > 0 && (
|
||||
<span className="text-blue-400">({Math.round(source.relevance * 100)}%)</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -116,11 +83,41 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
const RAG_API = '/api/sdk/v1/rag'
|
||||
|
||||
const DEFAULT_QUESTIONS = [
|
||||
'Was sind die Rechte der Betroffenen nach DSGVO?',
|
||||
'Wie lange betraegt die Meldefrist bei einer Datenpanne?',
|
||||
'Welche Anforderungen stellt der AI Act an Hochrisiko-KI?',
|
||||
'Wann brauche ich einen Auftragsverarbeitungsvertrag?',
|
||||
'Was muss in einer Datenschutzerklaerung stehen?',
|
||||
]
|
||||
|
||||
export default function RAGPage() {
|
||||
const { state } = useSDK()
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(mockHistory)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>(DEFAULT_QUESTIONS)
|
||||
const [apiError, setApiError] = useState<string | null>(null)
|
||||
|
||||
// Load regulations for suggested questions
|
||||
useEffect(() => {
|
||||
fetch(`${RAG_API}/regulations`)
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (data && Array.isArray(data.regulations) && data.regulations.length > 0) {
|
||||
const regs: Regulation[] = data.regulations
|
||||
const dynamicQuestions = regs.slice(0, 5).map((r: Regulation) =>
|
||||
`Was sind die wichtigsten Anforderungen aus ${r.name}?`
|
||||
)
|
||||
setSuggestedQuestions(dynamicQuestions.length > 0 ? dynamicQuestions : DEFAULT_QUESTIONS)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep default questions if API unavailable
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -136,22 +133,53 @@ export default function RAGPage() {
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
setIsLoading(true)
|
||||
setApiError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${RAG_API}/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: inputValue, limit: 5 }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// Build assistant message from results
|
||||
const results = data.results || []
|
||||
let content = ''
|
||||
const sources: Source[] = []
|
||||
|
||||
if (results.length === 0) {
|
||||
content = 'Zu dieser Frage wurden keine passenden Dokumente gefunden. Bitte formulieren Sie Ihre Frage anders oder waehlen Sie ein spezifischeres Thema.'
|
||||
} else {
|
||||
const snippets = results.map((r: any, i: number) => {
|
||||
const title = r.metadata?.title || r.metadata?.reference || `Dokument ${i + 1}`
|
||||
const ref = r.metadata?.reference || ''
|
||||
sources.push({ title, reference: ref, relevance: r.score || 0 })
|
||||
return `**${title}${ref ? ` (${ref})` : ''}**\n${r.content || ''}`
|
||||
})
|
||||
content = snippets.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `msg-${Date.now() + 1}`,
|
||||
role: 'assistant',
|
||||
content: 'Dies ist eine Platzhalter-Antwort. In der produktiven Version wird hier die Antwort des Legal RAG Systems angezeigt, das Ihre Frage auf Basis der integrierten Rechtsdokumente beantwortet.',
|
||||
sources: [
|
||||
{ title: 'DSGVO', reference: 'Art. 5', relevance: 0.9 },
|
||||
{ title: 'AI Act', reference: 'Art. 6', relevance: 0.8 },
|
||||
],
|
||||
content,
|
||||
sources,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
setMessages(prev => [...prev, assistantMessage])
|
||||
} catch (err) {
|
||||
setApiError('RAG-Backend nicht erreichbar. Bitte pruefen Sie die Verbindung zum AI Compliance SDK.')
|
||||
// Remove the user message that didn't get a response
|
||||
setMessages(prev => prev.filter(m => m.id !== userMessage.id))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuggestedQuestion = (question: string) => {
|
||||
@@ -184,6 +212,23 @@ export default function RAGPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Box */}
|
||||
{apiError && (
|
||||
<div className="flex-shrink-0 bg-red-50 border border-red-200 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p className="text-sm text-red-700">{apiError}</p>
|
||||
<button onClick={() => setApiError(null)} className="ml-auto text-red-400 hover:text-red-600">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50 rounded-xl border border-gray-200 p-6 space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
@@ -10,128 +10,70 @@ import { useSDK } from '@/lib/sdk'
|
||||
interface SecurityItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
description: string | null
|
||||
type: 'vulnerability' | 'misconfiguration' | 'compliance' | 'hardening'
|
||||
severity: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'open' | 'in-progress' | 'resolved' | 'accepted-risk'
|
||||
source: string
|
||||
source: string | null
|
||||
cve: string | null
|
||||
cvss: number | null
|
||||
affectedAsset: string
|
||||
assignedTo: string | null
|
||||
createdAt: Date
|
||||
dueDate: Date | null
|
||||
affected_asset: string | null
|
||||
assigned_to: string | null
|
||||
created_at: string
|
||||
due_date: string | null
|
||||
remediation: string | null
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
open: number
|
||||
in_progress: number
|
||||
critical: number
|
||||
high: number
|
||||
overdue: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface NewItem {
|
||||
title: string
|
||||
description: string
|
||||
type: string
|
||||
severity: string
|
||||
source: string
|
||||
cve: string
|
||||
cvss: string
|
||||
affected_asset: string
|
||||
assigned_to: string
|
||||
remediation: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockItems: SecurityItem[] = [
|
||||
{
|
||||
id: 'sec-001',
|
||||
title: 'SQL Injection in Login-Modul',
|
||||
description: 'Unzureichende Validierung von Benutzereingaben ermoeglicht SQL Injection',
|
||||
type: 'vulnerability',
|
||||
severity: 'critical',
|
||||
status: 'in-progress',
|
||||
source: 'Penetrationstest',
|
||||
cve: 'CVE-2024-12345',
|
||||
cvss: 9.8,
|
||||
affectedAsset: 'auth-service',
|
||||
assignedTo: 'Entwicklung',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
dueDate: new Date('2024-01-25'),
|
||||
remediation: 'Parameterisierte Queries verwenden, Input-Validierung implementieren',
|
||||
},
|
||||
{
|
||||
id: 'sec-002',
|
||||
title: 'Veraltete TLS-Version',
|
||||
description: 'Server unterstuetzt noch TLS 1.0 und 1.1',
|
||||
type: 'misconfiguration',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
source: 'Vulnerability Scanner',
|
||||
cve: null,
|
||||
cvss: 7.5,
|
||||
affectedAsset: 'web-server',
|
||||
assignedTo: null,
|
||||
createdAt: new Date('2024-01-18'),
|
||||
dueDate: new Date('2024-02-01'),
|
||||
remediation: 'TLS 1.2 als Minimum konfigurieren, TLS 1.3 bevorzugen',
|
||||
},
|
||||
{
|
||||
id: 'sec-003',
|
||||
title: 'Fehlende Content-Security-Policy',
|
||||
description: 'HTTP-Header CSP nicht konfiguriert',
|
||||
type: 'hardening',
|
||||
severity: 'medium',
|
||||
status: 'open',
|
||||
source: 'Security Audit',
|
||||
cve: null,
|
||||
cvss: 5.4,
|
||||
affectedAsset: 'website',
|
||||
assignedTo: 'DevOps',
|
||||
createdAt: new Date('2024-01-10'),
|
||||
dueDate: new Date('2024-02-15'),
|
||||
remediation: 'Strikte CSP-Header implementieren',
|
||||
},
|
||||
{
|
||||
id: 'sec-004',
|
||||
title: 'Unsichere Cookie-Konfiguration',
|
||||
description: 'Session-Cookies ohne Secure und HttpOnly Flags',
|
||||
type: 'misconfiguration',
|
||||
severity: 'medium',
|
||||
status: 'resolved',
|
||||
source: 'Code Review',
|
||||
cve: null,
|
||||
cvss: 5.3,
|
||||
affectedAsset: 'auth-service',
|
||||
assignedTo: 'Entwicklung',
|
||||
createdAt: new Date('2024-01-05'),
|
||||
dueDate: new Date('2024-01-15'),
|
||||
remediation: 'Cookie-Flags setzen: Secure, HttpOnly, SameSite',
|
||||
},
|
||||
{
|
||||
id: 'sec-005',
|
||||
title: 'Veraltete Abhaengigkeit lodash',
|
||||
description: 'Bekannte Schwachstelle in lodash < 4.17.21',
|
||||
type: 'vulnerability',
|
||||
severity: 'high',
|
||||
status: 'in-progress',
|
||||
source: 'SBOM Scan',
|
||||
cve: 'CVE-2021-23337',
|
||||
cvss: 7.2,
|
||||
affectedAsset: 'frontend-app',
|
||||
assignedTo: 'Entwicklung',
|
||||
createdAt: new Date('2024-01-20'),
|
||||
dueDate: new Date('2024-01-30'),
|
||||
remediation: 'Abhaengigkeit auf Version 4.17.21 oder hoeher aktualisieren',
|
||||
},
|
||||
{
|
||||
id: 'sec-006',
|
||||
title: 'Fehlende Verschluesselung at Rest',
|
||||
description: 'Datenbank-Backup ohne Verschluesselung',
|
||||
type: 'compliance',
|
||||
severity: 'high',
|
||||
status: 'accepted-risk',
|
||||
source: 'Compliance Audit',
|
||||
cve: null,
|
||||
cvss: null,
|
||||
affectedAsset: 'database-backup',
|
||||
assignedTo: 'IT Operations',
|
||||
createdAt: new Date('2024-01-08'),
|
||||
dueDate: null,
|
||||
remediation: 'Backup-Verschluesselung aktivieren (AES-256)',
|
||||
},
|
||||
]
|
||||
const EMPTY_NEW_ITEM: NewItem = {
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'vulnerability',
|
||||
severity: 'medium',
|
||||
source: '',
|
||||
cve: '',
|
||||
cvss: '',
|
||||
affected_asset: '',
|
||||
assigned_to: '',
|
||||
remediation: '',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function SecurityItemCard({ item }: { item: SecurityItem }) {
|
||||
function SecurityItemCard({
|
||||
item,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
}: {
|
||||
item: SecurityItem
|
||||
onEdit: (item: SecurityItem) => void
|
||||
onDelete: (id: string) => void
|
||||
onStatusChange: (id: string, status: string) => void
|
||||
}) {
|
||||
const typeLabels = {
|
||||
vulnerability: 'Schwachstelle',
|
||||
misconfiguration: 'Fehlkonfiguration',
|
||||
@@ -167,7 +109,7 @@ function SecurityItemCard({ item }: { item: SecurityItem }) {
|
||||
'accepted-risk': 'Akzeptiert',
|
||||
}
|
||||
|
||||
const isOverdue = item.dueDate && item.dueDate < new Date() && item.status !== 'resolved'
|
||||
const isOverdue = item.due_date && new Date(item.due_date) < new Date() && item.status !== 'resolved'
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
@@ -189,26 +131,30 @@ function SecurityItemCard({ item }: { item: SecurityItem }) {
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{item.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{item.description}</p>
|
||||
{item.description && <p className="text-sm text-gray-500 mt-1">{item.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Betroffenes Asset: </span>
|
||||
<span className="font-medium text-gray-700">{item.affectedAsset}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Quelle: </span>
|
||||
<span className="font-medium text-gray-700">{item.source}</span>
|
||||
</div>
|
||||
{item.affected_asset && (
|
||||
<div>
|
||||
<span className="text-gray-500">Betroffenes Asset: </span>
|
||||
<span className="font-medium text-gray-700">{item.affected_asset}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.source && (
|
||||
<div>
|
||||
<span className="text-gray-500">Quelle: </span>
|
||||
<span className="font-medium text-gray-700">{item.source}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.cve && (
|
||||
<div>
|
||||
<span className="text-gray-500">CVE: </span>
|
||||
<span className="font-mono text-gray-700">{item.cve}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.cvss && (
|
||||
{item.cvss !== null && (
|
||||
<div>
|
||||
<span className="text-gray-500">CVSS: </span>
|
||||
<span className={`font-bold ${
|
||||
@@ -218,40 +164,168 @@ function SecurityItemCard({ item }: { item: SecurityItem }) {
|
||||
}`}>{item.cvss}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Zugewiesen: </span>
|
||||
<span className="font-medium text-gray-700">{item.assignedTo || 'Nicht zugewiesen'}</span>
|
||||
</div>
|
||||
{item.dueDate && (
|
||||
{item.assigned_to && (
|
||||
<div>
|
||||
<span className="text-gray-500">Zugewiesen: </span>
|
||||
<span className="font-medium text-gray-700">{item.assigned_to}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.due_date && (
|
||||
<div className={isOverdue ? 'text-red-600' : ''}>
|
||||
<span className="text-gray-500">Frist: </span>
|
||||
<span className="font-medium">
|
||||
{item.dueDate.toLocaleDateString('de-DE')}
|
||||
{new Date(item.due_date).toLocaleDateString('de-DE')}
|
||||
{isOverdue && ' (ueberfaellig)'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-500">Empfohlene Massnahme: </span>
|
||||
<span className="text-sm text-gray-700">{item.remediation}</span>
|
||||
</div>
|
||||
{item.remediation && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-500">Empfohlene Massnahme: </span>
|
||||
<span className="text-sm text-gray-700">{item.remediation}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
Erstellt: {item.createdAt.toLocaleDateString('de-DE')}
|
||||
Erstellt: {new Date(item.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
{item.status !== 'resolved' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm bg-green-50 text-green-700 hover:bg-green-100 rounded-lg transition-colors">
|
||||
Als behoben markieren
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.status !== 'resolved' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onEdit(item)}
|
||||
className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onStatusChange(item.id, 'resolved')}
|
||||
className="px-3 py-1 text-sm bg-green-50 text-green-700 hover:bg-green-100 rounded-lg transition-colors"
|
||||
>
|
||||
Als behoben markieren
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`"${item.title}" loeschen?`)) onDelete(item.id)
|
||||
}}
|
||||
className="px-2 py-1 text-sm text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODAL
|
||||
// =============================================================================
|
||||
|
||||
function ItemModal({
|
||||
item,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
item: NewItem
|
||||
onClose: () => void
|
||||
onSave: (data: NewItem) => void
|
||||
}) {
|
||||
const [form, setForm] = useState<NewItem>(item)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">Sicherheitsbefund erfassen</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => setForm(p => ({ ...p, title: e.target.value }))}
|
||||
placeholder="Kurzbeschreibung des Befunds"
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => setForm(p => ({ ...p, description: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select value={form.type} onChange={e => setForm(p => ({ ...p, type: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="vulnerability">Schwachstelle</option>
|
||||
<option value="misconfiguration">Fehlkonfiguration</option>
|
||||
<option value="compliance">Compliance</option>
|
||||
<option value="hardening">Haertung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schweregrad</label>
|
||||
<select value={form.severity} onChange={e => setForm(p => ({ ...p, severity: e.target.value }))} className="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Quelle</label>
|
||||
<input type="text" value={form.source} onChange={e => setForm(p => ({ ...p, source: e.target.value }))} placeholder="z.B. Penetrationstest" className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betroffenes Asset</label>
|
||||
<input type="text" value={form.affected_asset} onChange={e => setForm(p => ({ ...p, affected_asset: e.target.value }))} placeholder="z.B. auth-service" className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">CVE</label>
|
||||
<input type="text" value={form.cve} onChange={e => setForm(p => ({ ...p, cve: e.target.value }))} placeholder="CVE-2024-XXXXX" className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">CVSS Score</label>
|
||||
<input type="number" step="0.1" min="0" max="10" value={form.cvss} onChange={e => setForm(p => ({ ...p, cvss: e.target.value }))} placeholder="0.0 - 10.0" className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zugewiesen an</label>
|
||||
<input type="text" value={form.assigned_to} onChange={e => setForm(p => ({ ...p, assigned_to: e.target.value }))} placeholder="Team oder Person" className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Massnahme</label>
|
||||
<textarea value={form.remediation} onChange={e => setForm(p => ({ ...p, remediation: e.target.value }))} rows={2} placeholder="Empfohlene Abhilfemassnahme..." className="w-full border rounded px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
<button
|
||||
onClick={() => onSave(form)}
|
||||
disabled={!form.title}
|
||||
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -261,22 +335,118 @@ function SecurityItemCard({ item }: { item: SecurityItem }) {
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
const API = '/api/sdk/v1/compliance/security-backlog'
|
||||
|
||||
export default function SecurityBacklogPage() {
|
||||
const { state } = useSDK()
|
||||
const [items] = useState<SecurityItem[]>(mockItems)
|
||||
const [items, setItems] = useState<SecurityItem[]>([])
|
||||
const [stats, setStats] = useState<Stats>({ open: 0, in_progress: 0, critical: 0, high: 0, overdue: 0, total: 0 })
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editItem, setEditItem] = useState<SecurityItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [itemsRes, statsRes] = await Promise.all([
|
||||
fetch(`${API}?limit=200`),
|
||||
fetch(`${API}/stats`),
|
||||
])
|
||||
if (itemsRes.ok) {
|
||||
const data = await itemsRes.json()
|
||||
setItems(Array.isArray(data.items) ? data.items : [])
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
const data = await statsRes.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load security backlog:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(form: NewItem) {
|
||||
try {
|
||||
const res = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
cvss: form.cvss ? parseFloat(form.cvss) : null,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setItems(prev => [created, ...prev])
|
||||
setStats(prev => ({ ...prev, open: prev.open + 1, total: prev.total + 1 }))
|
||||
setShowModal(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create item:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(form: NewItem) {
|
||||
if (!editItem) return
|
||||
try {
|
||||
const res = await fetch(`${API}/${editItem.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
cvss: form.cvss ? parseFloat(form.cvss) : null,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setItems(prev => prev.map(i => i.id === updated.id ? updated : i))
|
||||
setEditItem(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update item:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${API}/${id}`, { method: 'DELETE' })
|
||||
if (res.ok || res.status === 204) {
|
||||
setItems(prev => prev.filter(i => i.id !== id))
|
||||
loadData() // refresh stats
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete item:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
try {
|
||||
const res = await fetch(`${API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setItems(prev => prev.map(i => i.id === updated.id ? updated : i))
|
||||
loadData() // refresh stats
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = filter === 'all'
|
||||
? items
|
||||
: items.filter(i => i.severity === filter || i.status === filter || i.type === filter)
|
||||
|
||||
const openItems = items.filter(i => i.status === 'open').length
|
||||
const criticalCount = items.filter(i => i.severity === 'critical' && i.status !== 'resolved').length
|
||||
const highCount = items.filter(i => i.severity === 'high' && i.status !== 'resolved').length
|
||||
const overdueCount = items.filter(i =>
|
||||
i.dueDate && i.dueDate < new Date() && i.status !== 'resolved'
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -288,10 +458,10 @@ export default function SecurityBacklogPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
SBOM importieren
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<button
|
||||
onClick={() => { setEditItem(null); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
@@ -304,24 +474,24 @@ export default function SecurityBacklogPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Offen</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{openItems}</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{stats.open}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Kritisch</div>
|
||||
<div className="text-3xl font-bold text-red-600">{criticalCount}</div>
|
||||
<div className="text-3xl font-bold text-red-600">{stats.critical}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hoch</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highCount}</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{stats.high}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">Ueberfaellig</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{overdueCount}</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.overdue}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Alert */}
|
||||
{criticalCount > 0 && (
|
||||
{stats.critical > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -329,10 +499,8 @@ export default function SecurityBacklogPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-red-800">{criticalCount} kritische Schwachstelle(n) erfordern sofortige Aufmerksamkeit</h4>
|
||||
<p className="text-sm text-red-600">
|
||||
Diese Befunde haben ein CVSS von 9.0 oder hoeher und sollten priorisiert werden.
|
||||
</p>
|
||||
<h4 className="font-medium text-red-800">{stats.critical} kritische Schwachstelle(n) erfordern sofortige Aufmerksamkeit</h4>
|
||||
<p className="text-sm text-red-600">Diese Befunde haben ein CVSS von 9.0 oder hoeher und sollten priorisiert werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -345,47 +513,73 @@ export default function SecurityBacklogPage() {
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'open' ? 'Offen' :
|
||||
f === 'in-progress' ? 'In Bearbeitung' :
|
||||
f === 'critical' ? 'Kritisch' :
|
||||
f === 'high' ? 'Hoch' :
|
||||
{f === 'all' ? 'Alle' : f === 'open' ? 'Offen' : f === 'in-progress' ? 'In Bearbeitung' :
|
||||
f === 'critical' ? 'Kritisch' : f === 'high' ? 'Hoch' :
|
||||
f === 'vulnerability' ? 'Schwachstellen' : 'Fehlkonfigurationen'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div className="space-y-4">
|
||||
{filteredItems
|
||||
.sort((a, b) => {
|
||||
// Sort by severity and status
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const statusOrder = { open: 0, 'in-progress': 1, 'accepted-risk': 2, resolved: 3 }
|
||||
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
|
||||
if (severityDiff !== 0) return severityDiff
|
||||
return statusOrder[a.status] - statusOrder[b.status]
|
||||
})
|
||||
.map(item => (
|
||||
<SecurityItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Befunde gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder fuehren Sie einen neuen Scan durch.</p>
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center text-gray-400">
|
||||
Lade Sicherheitsbefunde...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredItems
|
||||
.sort((a, b) => {
|
||||
const sOrder = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const stOrder = { open: 0, 'in-progress': 1, 'accepted-risk': 2, resolved: 3 }
|
||||
const sd = sOrder[a.severity] - sOrder[b.severity]
|
||||
if (sd !== 0) return sd
|
||||
return stOrder[a.status] - stOrder[b.status]
|
||||
})
|
||||
.map(item => (
|
||||
<SecurityItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onEdit={i => { setEditItem(i); setShowModal(true) }}
|
||||
onDelete={handleDelete}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Befunde gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder erfassen Sie einen neuen Befund.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<ItemModal
|
||||
item={editItem ? {
|
||||
title: editItem.title,
|
||||
description: editItem.description || '',
|
||||
type: editItem.type,
|
||||
severity: editItem.severity,
|
||||
source: editItem.source || '',
|
||||
cve: editItem.cve || '',
|
||||
cvss: editItem.cvss !== null ? String(editItem.cvss) : '',
|
||||
affected_asset: editItem.affected_asset || '',
|
||||
assigned_to: editItem.assigned_to || '',
|
||||
remediation: editItem.remediation || '',
|
||||
} : EMPTY_NEW_ITEM}
|
||||
onClose={() => { setShowModal(false); setEditItem(null) }}
|
||||
onSave={editItem ? handleUpdate : handleCreate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
100
admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts
Normal file
100
admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* RAG API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/rag/* requests to ai-compliance-sdk backend (Port 8090)
|
||||
*/
|
||||
|
||||
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/rag`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const userHeader = request.headers.get('x-user-id')
|
||||
headers['X-User-ID'] = (userHeader && uuidRegex.test(userHeader)) ? userHeader : '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const tenantHeader = request.headers.get('x-tenant-id')
|
||||
headers['X-Tenant-ID'] = (tenantHeader && uuidRegex.test(tenantHeader)) ? tenantHeader : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text && text.trim()) {
|
||||
fetchOptions.body = text
|
||||
}
|
||||
} catch {
|
||||
// Empty or invalid body - continue without
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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('RAG API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum RAG 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')
|
||||
}
|
||||
@@ -16,6 +16,9 @@ from .escalation_routes import router as escalation_router
|
||||
from .consent_template_routes import router as consent_template_router
|
||||
from .notfallplan_routes import router as notfallplan_router
|
||||
from .obligation_routes import router as obligation_router
|
||||
from .security_backlog_routes import router as security_backlog_router
|
||||
from .quality_routes import router as quality_router
|
||||
from .loeschfristen_routes import router as loeschfristen_router
|
||||
|
||||
# Include sub-routers
|
||||
router.include_router(audit_router)
|
||||
@@ -33,6 +36,9 @@ router.include_router(escalation_router)
|
||||
router.include_router(consent_template_router)
|
||||
router.include_router(notfallplan_router)
|
||||
router.include_router(obligation_router)
|
||||
router.include_router(security_backlog_router)
|
||||
router.include_router(quality_router)
|
||||
router.include_router(loeschfristen_router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
@@ -51,4 +57,7 @@ __all__ = [
|
||||
"consent_template_router",
|
||||
"notfallplan_router",
|
||||
"obligation_router",
|
||||
"security_backlog_router",
|
||||
"quality_router",
|
||||
"loeschfristen_router",
|
||||
]
|
||||
|
||||
354
backend-compliance/compliance/api/loeschfristen_routes.py
Normal file
354
backend-compliance/compliance/api/loeschfristen_routes.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
FastAPI routes for Loeschfristen (Retention Policies).
|
||||
|
||||
Endpoints:
|
||||
GET /loeschfristen — list (filter: status, retention_driver, search; limit/offset)
|
||||
GET /loeschfristen/stats — total, active, draft, review_needed, archived, legal_holds_count
|
||||
POST /loeschfristen — create
|
||||
GET /loeschfristen/{id} — get single
|
||||
PUT /loeschfristen/{id} — full update
|
||||
PUT /loeschfristen/{id}/status — quick status update
|
||||
DELETE /loeschfristen/{id} — delete (204)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/loeschfristen", tags=["loeschfristen"])
|
||||
|
||||
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
class LoeschfristCreate(BaseModel):
|
||||
policy_id: Optional[str] = None
|
||||
data_object_name: str
|
||||
description: Optional[str] = None
|
||||
affected_groups: Optional[List[Any]] = None
|
||||
data_categories: Optional[List[Any]] = None
|
||||
primary_purpose: Optional[str] = None
|
||||
deletion_trigger: str = "PURPOSE_END"
|
||||
retention_driver: Optional[str] = None
|
||||
retention_driver_detail: Optional[str] = None
|
||||
retention_duration: Optional[int] = None
|
||||
retention_unit: Optional[str] = None
|
||||
retention_description: Optional[str] = None
|
||||
start_event: Optional[str] = None
|
||||
has_active_legal_hold: bool = False
|
||||
legal_holds: Optional[List[Any]] = None
|
||||
storage_locations: Optional[List[Any]] = None
|
||||
deletion_method: Optional[str] = None
|
||||
deletion_method_detail: Optional[str] = None
|
||||
responsible_role: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
release_process: Optional[str] = None
|
||||
linked_vvt_activity_ids: Optional[List[Any]] = None
|
||||
status: str = "DRAFT"
|
||||
last_review_date: Optional[datetime] = None
|
||||
next_review_date: Optional[datetime] = None
|
||||
review_interval: Optional[str] = None
|
||||
tags: Optional[List[Any]] = None
|
||||
|
||||
|
||||
class LoeschfristUpdate(BaseModel):
|
||||
policy_id: Optional[str] = None
|
||||
data_object_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
affected_groups: Optional[List[Any]] = None
|
||||
data_categories: Optional[List[Any]] = None
|
||||
primary_purpose: Optional[str] = None
|
||||
deletion_trigger: Optional[str] = None
|
||||
retention_driver: Optional[str] = None
|
||||
retention_driver_detail: Optional[str] = None
|
||||
retention_duration: Optional[int] = None
|
||||
retention_unit: Optional[str] = None
|
||||
retention_description: Optional[str] = None
|
||||
start_event: Optional[str] = None
|
||||
has_active_legal_hold: Optional[bool] = None
|
||||
legal_holds: Optional[List[Any]] = None
|
||||
storage_locations: Optional[List[Any]] = None
|
||||
deletion_method: Optional[str] = None
|
||||
deletion_method_detail: Optional[str] = None
|
||||
responsible_role: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
release_process: Optional[str] = None
|
||||
linked_vvt_activity_ids: Optional[List[Any]] = None
|
||||
status: Optional[str] = None
|
||||
last_review_date: Optional[datetime] = None
|
||||
next_review_date: Optional[datetime] = None
|
||||
review_interval: Optional[str] = None
|
||||
tags: Optional[List[Any]] = None
|
||||
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
# JSONB fields that need CAST
|
||||
JSONB_FIELDS = {
|
||||
"affected_groups", "data_categories", "legal_holds",
|
||||
"storage_locations", "linked_vvt_activity_ids", "tags"
|
||||
}
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
result = dict(row._mapping)
|
||||
for key, val in result.items():
|
||||
if isinstance(val, datetime):
|
||||
result[key] = val.isoformat()
|
||||
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
|
||||
result[key] = str(val)
|
||||
return result
|
||||
|
||||
|
||||
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
|
||||
if x_tenant_id:
|
||||
try:
|
||||
UUID(x_tenant_id)
|
||||
return x_tenant_id
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("")
|
||||
async def list_loeschfristen(
|
||||
status: Optional[str] = Query(None),
|
||||
retention_driver: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List Loeschfristen with optional filters."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if status:
|
||||
where_clauses.append("status = :status")
|
||||
params["status"] = status
|
||||
if retention_driver:
|
||||
where_clauses.append("retention_driver = :retention_driver")
|
||||
params["retention_driver"] = retention_driver
|
||||
if search:
|
||||
where_clauses.append("(data_object_name ILIKE :search OR description ILIKE :search OR policy_id ILIKE :search)")
|
||||
params["search"] = f"%{search}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_loeschfristen WHERE {where_sql}"),
|
||||
params,
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_loeschfristen
|
||||
WHERE {where_sql}
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'ACTIVE' THEN 0
|
||||
WHEN 'REVIEW_NEEDED' THEN 1
|
||||
WHEN 'DRAFT' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"policies": [_row_to_dict(r) for r in rows],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_loeschfristen_stats(
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Return Loeschfristen statistics."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'ACTIVE') AS active,
|
||||
COUNT(*) FILTER (WHERE status = 'DRAFT') AS draft,
|
||||
COUNT(*) FILTER (WHERE status = 'REVIEW_NEEDED') AS review_needed,
|
||||
COUNT(*) FILTER (WHERE status = 'ARCHIVED') AS archived,
|
||||
COUNT(*) FILTER (WHERE has_active_legal_hold = TRUE) AS legal_holds_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE next_review_date IS NOT NULL
|
||||
AND next_review_date < NOW()
|
||||
AND status NOT IN ('ARCHIVED')
|
||||
) AS overdue_reviews
|
||||
FROM compliance_loeschfristen
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
if row:
|
||||
d = dict(row._mapping)
|
||||
return {k: int(v or 0) for k, v in d.items()}
|
||||
return {"total": 0, "active": 0, "draft": 0, "review_needed": 0,
|
||||
"archived": 0, "legal_holds_count": 0, "overdue_reviews": 0}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_loeschfrist(
|
||||
payload: LoeschfristCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new Loeschfrist policy."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
data = payload.model_dump()
|
||||
|
||||
# Build INSERT with JSONB casts
|
||||
columns = ["tenant_id"] + list(data.keys())
|
||||
value_parts = [":tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id}
|
||||
|
||||
for k, v in data.items():
|
||||
if k in JSONB_FIELDS:
|
||||
value_parts.append(f"CAST(:{k} AS jsonb)")
|
||||
params[k] = json.dumps(v if v is not None else [])
|
||||
else:
|
||||
value_parts.append(f":{k}")
|
||||
params[k] = v
|
||||
|
||||
cols_sql = ", ".join(columns)
|
||||
vals_sql = ", ".join(value_parts)
|
||||
|
||||
row = db.execute(
|
||||
text(f"INSERT INTO compliance_loeschfristen ({cols_sql}) VALUES ({vals_sql}) RETURNING *"),
|
||||
params,
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/{policy_id}")
|
||||
async def get_loeschfrist(
|
||||
policy_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
row = db.execute(
|
||||
text("SELECT * FROM compliance_loeschfristen WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": policy_id, "tenant_id": tenant_id},
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/{policy_id}")
|
||||
async def update_loeschfrist(
|
||||
policy_id: str,
|
||||
payload: LoeschfristUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Full update of a Loeschfrist policy."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": policy_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
if field in JSONB_FIELDS:
|
||||
updates[field] = json.dumps(value if value is not None else [])
|
||||
set_clauses.append(f"{field} = CAST(:{field} AS jsonb)")
|
||||
else:
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(
|
||||
text(f"""
|
||||
UPDATE compliance_loeschfristen
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""),
|
||||
updates,
|
||||
).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/{policy_id}/status")
|
||||
async def update_loeschfrist_status(
|
||||
policy_id: str,
|
||||
payload: StatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Quick status update."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
valid = {"DRAFT", "ACTIVE", "REVIEW_NEEDED", "ARCHIVED"}
|
||||
if payload.status not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {', '.join(valid)}")
|
||||
|
||||
row = db.execute(
|
||||
text("""
|
||||
UPDATE compliance_loeschfristen
|
||||
SET status = :status, updated_at = :now
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""),
|
||||
{"status": payload.status, "now": datetime.utcnow(), "id": policy_id, "tenant_id": tenant_id},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/{policy_id}", status_code=204)
|
||||
async def delete_loeschfrist(
|
||||
policy_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(
|
||||
text("DELETE FROM compliance_loeschfristen WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": policy_id, "tenant_id": tenant_id},
|
||||
)
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
||||
@@ -691,9 +691,328 @@ async def get_stats(
|
||||
{"tenant_id": tenant_id},
|
||||
).scalar()
|
||||
|
||||
incidents_count = db.execute(
|
||||
text("SELECT COUNT(*) FROM compliance_notfallplan_incidents WHERE tenant_id = :tenant_id AND status != 'closed'"),
|
||||
{"tenant_id": tenant_id},
|
||||
).scalar()
|
||||
|
||||
return {
|
||||
"contacts": contacts_count or 0,
|
||||
"active_scenarios": scenarios_count or 0,
|
||||
"exercises": exercises_count or 0,
|
||||
"checklist_items": checklists_count or 0,
|
||||
"open_incidents": incidents_count or 0,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Incidents
|
||||
# ============================================================================
|
||||
|
||||
class IncidentCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
detected_by: Optional[str] = None
|
||||
status: str = 'detected'
|
||||
severity: str = 'medium'
|
||||
affected_data_categories: List[Any] = []
|
||||
estimated_affected_persons: int = 0
|
||||
measures: List[Any] = []
|
||||
art34_required: bool = False
|
||||
art34_justification: Optional[str] = None
|
||||
|
||||
|
||||
class IncidentUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
detected_by: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
affected_data_categories: Optional[List[Any]] = None
|
||||
estimated_affected_persons: Optional[int] = None
|
||||
measures: Optional[List[Any]] = None
|
||||
art34_required: Optional[bool] = None
|
||||
art34_justification: Optional[str] = None
|
||||
reported_to_authority_at: Optional[str] = None
|
||||
notified_affected_at: Optional[str] = None
|
||||
closed_at: Optional[str] = None
|
||||
closed_by: Optional[str] = None
|
||||
lessons_learned: Optional[str] = None
|
||||
|
||||
|
||||
def _incident_row(r) -> dict:
|
||||
return {
|
||||
"id": str(r.id),
|
||||
"tenant_id": r.tenant_id,
|
||||
"title": r.title,
|
||||
"description": r.description,
|
||||
"detected_at": r.detected_at.isoformat() if r.detected_at else None,
|
||||
"detected_by": r.detected_by,
|
||||
"status": r.status,
|
||||
"severity": r.severity,
|
||||
"affected_data_categories": r.affected_data_categories if r.affected_data_categories else [],
|
||||
"estimated_affected_persons": r.estimated_affected_persons,
|
||||
"measures": r.measures if r.measures else [],
|
||||
"art34_required": r.art34_required,
|
||||
"art34_justification": r.art34_justification,
|
||||
"reported_to_authority_at": r.reported_to_authority_at.isoformat() if r.reported_to_authority_at else None,
|
||||
"notified_affected_at": r.notified_affected_at.isoformat() if r.notified_affected_at else None,
|
||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||
"closed_by": r.closed_by,
|
||||
"lessons_learned": r.lessons_learned,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/incidents")
|
||||
async def list_incidents(
|
||||
status: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""List all incidents for a tenant."""
|
||||
where = "WHERE tenant_id = :tenant_id"
|
||||
params: dict = {"tenant_id": tenant_id}
|
||||
|
||||
if status:
|
||||
where += " AND status = :status"
|
||||
params["status"] = status
|
||||
if severity:
|
||||
where += " AND severity = :severity"
|
||||
params["severity"] = severity
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_notfallplan_incidents
|
||||
{where}
|
||||
ORDER BY created_at DESC
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
return [_incident_row(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/incidents", status_code=201)
|
||||
async def create_incident(
|
||||
request: IncidentCreate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Create a new incident."""
|
||||
row = db.execute(
|
||||
text("""
|
||||
INSERT INTO compliance_notfallplan_incidents
|
||||
(tenant_id, title, description, detected_by, status, severity,
|
||||
affected_data_categories, estimated_affected_persons, measures,
|
||||
art34_required, art34_justification)
|
||||
VALUES
|
||||
(:tenant_id, :title, :description, :detected_by, :status, :severity,
|
||||
CAST(:affected_data_categories AS jsonb), :estimated_affected_persons,
|
||||
CAST(:measures AS jsonb), :art34_required, :art34_justification)
|
||||
RETURNING *
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"title": request.title,
|
||||
"description": request.description,
|
||||
"detected_by": request.detected_by,
|
||||
"status": request.status,
|
||||
"severity": request.severity,
|
||||
"affected_data_categories": json.dumps(request.affected_data_categories),
|
||||
"estimated_affected_persons": request.estimated_affected_persons,
|
||||
"measures": json.dumps(request.measures),
|
||||
"art34_required": request.art34_required,
|
||||
"art34_justification": request.art34_justification,
|
||||
},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _incident_row(row)
|
||||
|
||||
|
||||
@router.put("/incidents/{incident_id}")
|
||||
async def update_incident(
|
||||
incident_id: str,
|
||||
request: IncidentUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Update an incident (including status transitions)."""
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM compliance_notfallplan_incidents WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": incident_id, "tenant_id": tenant_id},
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Incident {incident_id} not found")
|
||||
|
||||
updates = request.dict(exclude_none=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
# Auto-set timestamps based on status transitions
|
||||
if updates.get("status") == "reported" and not updates.get("reported_to_authority_at"):
|
||||
updates["reported_to_authority_at"] = datetime.utcnow().isoformat()
|
||||
if updates.get("status") == "closed" and not updates.get("closed_at"):
|
||||
updates["closed_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
set_parts = []
|
||||
for k in updates:
|
||||
if k in ("affected_data_categories", "measures"):
|
||||
set_parts.append(f"{k} = CAST(:{k} AS jsonb)")
|
||||
updates[k] = json.dumps(updates[k]) if isinstance(updates[k], list) else updates[k]
|
||||
else:
|
||||
set_parts.append(f"{k} = :{k}")
|
||||
|
||||
updates["id"] = incident_id
|
||||
updates["tenant_id"] = tenant_id
|
||||
|
||||
row = db.execute(
|
||||
text(f"""
|
||||
UPDATE compliance_notfallplan_incidents
|
||||
SET {', '.join(set_parts)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""),
|
||||
updates,
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _incident_row(row)
|
||||
|
||||
|
||||
@router.delete("/incidents/{incident_id}", status_code=204)
|
||||
async def delete_incident(
|
||||
incident_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Delete an incident."""
|
||||
result = db.execute(
|
||||
text("DELETE FROM compliance_notfallplan_incidents WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": incident_id, "tenant_id": tenant_id},
|
||||
)
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail=f"Incident {incident_id} not found")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Templates
|
||||
# ============================================================================
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
type: str = 'art33'
|
||||
title: str
|
||||
content: str
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
type: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
def _template_row(r) -> dict:
|
||||
return {
|
||||
"id": str(r.id),
|
||||
"tenant_id": r.tenant_id,
|
||||
"type": r.type,
|
||||
"title": r.title,
|
||||
"content": r.content,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(
|
||||
type: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""List Melde-Templates for a tenant."""
|
||||
where = "WHERE tenant_id = :tenant_id"
|
||||
params: dict = {"tenant_id": tenant_id}
|
||||
if type:
|
||||
where += " AND type = :type"
|
||||
params["type"] = type
|
||||
|
||||
rows = db.execute(
|
||||
text(f"SELECT * FROM compliance_notfallplan_templates {where} ORDER BY type, created_at"),
|
||||
params,
|
||||
).fetchall()
|
||||
return [_template_row(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/templates", status_code=201)
|
||||
async def create_template(
|
||||
request: TemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Create a new Melde-Template."""
|
||||
row = db.execute(
|
||||
text("""
|
||||
INSERT INTO compliance_notfallplan_templates (tenant_id, type, title, content)
|
||||
VALUES (:tenant_id, :type, :title, :content)
|
||||
RETURNING *
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "type": request.type, "title": request.title, "content": request.content},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _template_row(row)
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
async def update_template(
|
||||
template_id: str,
|
||||
request: TemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Update a Melde-Template."""
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM compliance_notfallplan_templates WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": template_id, "tenant_id": tenant_id},
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
|
||||
|
||||
updates = request.dict(exclude_none=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
|
||||
updates["id"] = template_id
|
||||
updates["tenant_id"] = tenant_id
|
||||
|
||||
row = db.execute(
|
||||
text(f"""
|
||||
UPDATE compliance_notfallplan_templates
|
||||
SET {set_clauses}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""),
|
||||
updates,
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _template_row(row)
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}", status_code=204)
|
||||
async def delete_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Delete a Melde-Template."""
|
||||
result = db.execute(
|
||||
text("DELETE FROM compliance_notfallplan_templates WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": template_id, "tenant_id": tenant_id},
|
||||
)
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
|
||||
|
||||
378
backend-compliance/compliance/api/quality_routes.py
Normal file
378
backend-compliance/compliance/api/quality_routes.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
FastAPI routes for AI Quality Metrics and Tests.
|
||||
|
||||
Endpoints:
|
||||
GET/POST /quality/metrics — list/create metrics
|
||||
PUT/DELETE /quality/metrics/{id} — update/delete metric
|
||||
GET/POST /quality/tests — list/create tests
|
||||
PUT/DELETE /quality/tests/{id} — update/delete test
|
||||
GET /quality/stats — avgScore, metricsAboveThreshold, passed, failed
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/quality", tags=["quality"])
|
||||
|
||||
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
class MetricCreate(BaseModel):
|
||||
name: str
|
||||
category: str = "accuracy"
|
||||
score: float = 0.0
|
||||
threshold: float = 80.0
|
||||
trend: str = "stable"
|
||||
ai_system: Optional[str] = None
|
||||
last_measured: Optional[datetime] = None
|
||||
|
||||
|
||||
class MetricUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
score: Optional[float] = None
|
||||
threshold: Optional[float] = None
|
||||
trend: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
last_measured: Optional[datetime] = None
|
||||
|
||||
|
||||
class TestCreate(BaseModel):
|
||||
name: str
|
||||
status: str = "pending"
|
||||
duration: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
details: Optional[str] = None
|
||||
last_run: Optional[datetime] = None
|
||||
|
||||
|
||||
class TestUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
duration: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
details: Optional[str] = None
|
||||
last_run: Optional[datetime] = None
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
result = dict(row._mapping)
|
||||
for key, val in result.items():
|
||||
if isinstance(val, datetime):
|
||||
result[key] = val.isoformat()
|
||||
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
|
||||
result[key] = str(val)
|
||||
return result
|
||||
|
||||
|
||||
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
|
||||
if x_tenant_id:
|
||||
try:
|
||||
UUID(x_tenant_id)
|
||||
return x_tenant_id
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stats
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_quality_stats(
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Return quality dashboard stats."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
metrics_row = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) AS total_metrics,
|
||||
COALESCE(AVG(score), 0) AS avg_score,
|
||||
COUNT(*) FILTER (WHERE score >= threshold) AS metrics_above_threshold
|
||||
FROM compliance_quality_metrics
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
tests_row = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'passed') AS passed,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
|
||||
COUNT(*) FILTER (WHERE status = 'warning') AS warning,
|
||||
COUNT(*) AS total
|
||||
FROM compliance_quality_tests
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
return {
|
||||
"total_metrics": int(metrics_row.total_metrics or 0),
|
||||
"avg_score": round(float(metrics_row.avg_score or 0), 1),
|
||||
"metrics_above_threshold": int(metrics_row.metrics_above_threshold or 0),
|
||||
"passed": int(tests_row.passed or 0),
|
||||
"failed": int(tests_row.failed or 0),
|
||||
"warning": int(tests_row.warning or 0),
|
||||
"total_tests": int(tests_row.total or 0),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Metrics
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/metrics")
|
||||
async def list_metrics(
|
||||
category: Optional[str] = Query(None),
|
||||
ai_system: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List quality metrics."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if category:
|
||||
where_clauses.append("category = :category")
|
||||
params["category"] = category
|
||||
if ai_system:
|
||||
where_clauses.append("ai_system ILIKE :ai_system")
|
||||
params["ai_system"] = f"%{ai_system}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_quality_metrics WHERE {where_sql}"), params
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_quality_metrics
|
||||
WHERE {where_sql}
|
||||
ORDER BY category, name
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {"metrics": [_row_to_dict(r) for r in rows], "total": total}
|
||||
|
||||
|
||||
@router.post("/metrics", status_code=201)
|
||||
async def create_metric(
|
||||
payload: MetricCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new quality metric."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_quality_metrics
|
||||
(tenant_id, name, category, score, threshold, trend, ai_system, last_measured)
|
||||
VALUES
|
||||
(:tenant_id, :name, :category, :score, :threshold, :trend, :ai_system, :last_measured)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
"name": payload.name,
|
||||
"category": payload.category,
|
||||
"score": payload.score,
|
||||
"threshold": payload.threshold,
|
||||
"trend": payload.trend,
|
||||
"ai_system": payload.ai_system,
|
||||
"last_measured": payload.last_measured or datetime.utcnow(),
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/metrics/{metric_id}")
|
||||
async def update_metric(
|
||||
metric_id: str,
|
||||
payload: MetricUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update a quality metric."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": metric_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(text(f"""
|
||||
UPDATE compliance_quality_metrics
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""), updates).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Metric not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/metrics/{metric_id}", status_code=204)
|
||||
async def delete_metric(
|
||||
metric_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_quality_metrics
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
"""), {"id": metric_id, "tenant_id": tenant_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Metric not found")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/tests")
|
||||
async def list_tests(
|
||||
status: Optional[str] = Query(None),
|
||||
ai_system: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List quality tests."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if status:
|
||||
where_clauses.append("status = :status")
|
||||
params["status"] = status
|
||||
if ai_system:
|
||||
where_clauses.append("ai_system ILIKE :ai_system")
|
||||
params["ai_system"] = f"%{ai_system}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_quality_tests WHERE {where_sql}"), params
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_quality_tests
|
||||
WHERE {where_sql}
|
||||
ORDER BY last_run DESC NULLS LAST, created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {"tests": [_row_to_dict(r) for r in rows], "total": total}
|
||||
|
||||
|
||||
@router.post("/tests", status_code=201)
|
||||
async def create_test(
|
||||
payload: TestCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new quality test entry."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_quality_tests
|
||||
(tenant_id, name, status, duration, ai_system, details, last_run)
|
||||
VALUES
|
||||
(:tenant_id, :name, :status, :duration, :ai_system, :details, :last_run)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
"name": payload.name,
|
||||
"status": payload.status,
|
||||
"duration": payload.duration,
|
||||
"ai_system": payload.ai_system,
|
||||
"details": payload.details,
|
||||
"last_run": payload.last_run or datetime.utcnow(),
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/tests/{test_id}")
|
||||
async def update_test(
|
||||
test_id: str,
|
||||
payload: TestUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update a quality test."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": test_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(text(f"""
|
||||
UPDATE compliance_quality_tests
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""), updates).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Test not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/tests/{test_id}", status_code=204)
|
||||
async def delete_test(
|
||||
test_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_quality_tests
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
"""), {"id": test_id, "tenant_id": tenant_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Test not found")
|
||||
270
backend-compliance/compliance/api/security_backlog_routes.py
Normal file
270
backend-compliance/compliance/api/security_backlog_routes.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
FastAPI routes for Security Backlog Tracking.
|
||||
|
||||
Endpoints:
|
||||
GET /security-backlog — list with filters (status, severity, type, search; limit/offset)
|
||||
GET /security-backlog/stats — open, critical, high, overdue counts
|
||||
POST /security-backlog — create finding
|
||||
PUT /security-backlog/{id} — update finding
|
||||
DELETE /security-backlog/{id} — delete finding (204)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/security-backlog", tags=["security-backlog"])
|
||||
|
||||
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
class SecurityItemCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
type: str = "vulnerability"
|
||||
severity: str = "medium"
|
||||
status: str = "open"
|
||||
source: Optional[str] = None
|
||||
cve: Optional[str] = None
|
||||
cvss: Optional[float] = None
|
||||
affected_asset: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
remediation: Optional[str] = None
|
||||
|
||||
|
||||
class SecurityItemUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
cve: Optional[str] = None
|
||||
cvss: Optional[float] = None
|
||||
affected_asset: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
remediation: Optional[str] = None
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
result = dict(row._mapping)
|
||||
for key, val in result.items():
|
||||
if isinstance(val, datetime):
|
||||
result[key] = val.isoformat()
|
||||
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
|
||||
result[key] = str(val)
|
||||
return result
|
||||
|
||||
|
||||
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
|
||||
if x_tenant_id:
|
||||
try:
|
||||
UUID(x_tenant_id)
|
||||
return x_tenant_id
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("")
|
||||
async def list_security_items(
|
||||
status: Optional[str] = Query(None),
|
||||
severity: Optional[str] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List security backlog items with optional filters."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if status:
|
||||
where_clauses.append("status = :status")
|
||||
params["status"] = status
|
||||
if severity:
|
||||
where_clauses.append("severity = :severity")
|
||||
params["severity"] = severity
|
||||
if type:
|
||||
where_clauses.append("type = :type")
|
||||
params["type"] = type
|
||||
if search:
|
||||
where_clauses.append("(title ILIKE :search OR description ILIKE :search)")
|
||||
params["search"] = f"%{search}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_security_backlog WHERE {where_sql}"),
|
||||
params,
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_security_backlog
|
||||
WHERE {where_sql}
|
||||
ORDER BY
|
||||
CASE severity
|
||||
WHEN 'critical' THEN 0
|
||||
WHEN 'high' THEN 1
|
||||
WHEN 'medium' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
CASE status
|
||||
WHEN 'open' THEN 0
|
||||
WHEN 'in-progress' THEN 1
|
||||
WHEN 'accepted-risk' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [_row_to_dict(r) for r in rows],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_security_stats(
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Return security backlog counts."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
rows = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'open') AS open,
|
||||
COUNT(*) FILTER (WHERE status = 'in-progress') AS in_progress,
|
||||
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved,
|
||||
COUNT(*) FILTER (WHERE status = 'accepted-risk') AS accepted_risk,
|
||||
COUNT(*) FILTER (WHERE severity = 'critical' AND status != 'resolved') AS critical,
|
||||
COUNT(*) FILTER (WHERE severity = 'high' AND status != 'resolved') AS high,
|
||||
COUNT(*) FILTER (
|
||||
WHERE due_date IS NOT NULL
|
||||
AND due_date < NOW()
|
||||
AND status NOT IN ('resolved', 'accepted-risk')
|
||||
) AS overdue,
|
||||
COUNT(*) AS total
|
||||
FROM compliance_security_backlog
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
if rows:
|
||||
d = dict(rows._mapping)
|
||||
return {k: (v or 0) for k, v in d.items()}
|
||||
return {"open": 0, "in_progress": 0, "resolved": 0, "accepted_risk": 0,
|
||||
"critical": 0, "high": 0, "overdue": 0, "total": 0}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_security_item(
|
||||
payload: SecurityItemCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new security backlog item."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_security_backlog
|
||||
(tenant_id, title, description, type, severity, status,
|
||||
source, cve, cvss, affected_asset, assigned_to, due_date, remediation)
|
||||
VALUES
|
||||
(:tenant_id, :title, :description, :type, :severity, :status,
|
||||
:source, :cve, :cvss, :affected_asset, :assigned_to, :due_date, :remediation)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
"title": payload.title,
|
||||
"description": payload.description,
|
||||
"type": payload.type,
|
||||
"severity": payload.severity,
|
||||
"status": payload.status,
|
||||
"source": payload.source,
|
||||
"cve": payload.cve,
|
||||
"cvss": payload.cvss,
|
||||
"affected_asset": payload.affected_asset,
|
||||
"assigned_to": payload.assigned_to,
|
||||
"due_date": payload.due_date,
|
||||
"remediation": payload.remediation,
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/{item_id}")
|
||||
async def update_security_item(
|
||||
item_id: str,
|
||||
payload: SecurityItemUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update a security backlog item."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": item_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(text(f"""
|
||||
UPDATE compliance_security_backlog
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""), updates).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Security item not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/{item_id}", status_code=204)
|
||||
async def delete_security_item(
|
||||
item_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_security_backlog
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
"""), {"id": item_id, "tenant_id": tenant_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Security item not found")
|
||||
28
backend-compliance/migrations/014_security_backlog.sql
Normal file
28
backend-compliance/migrations/014_security_backlog.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Migration 014: Security Backlog
|
||||
-- Tracking security findings, vulnerabilities, and compliance issues
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_security_backlog (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL DEFAULT '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'vulnerability',
|
||||
-- vulnerability | misconfiguration | compliance | hardening
|
||||
severity TEXT NOT NULL DEFAULT 'medium',
|
||||
-- critical | high | medium | low
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
-- open | in-progress | resolved | accepted-risk
|
||||
source TEXT,
|
||||
cve TEXT,
|
||||
cvss NUMERIC(4,1),
|
||||
affected_asset TEXT,
|
||||
assigned_to TEXT,
|
||||
due_date TIMESTAMPTZ,
|
||||
remediation TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_security_backlog_tenant ON compliance_security_backlog(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_security_backlog_status ON compliance_security_backlog(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_security_backlog_severity ON compliance_security_backlog(severity);
|
||||
36
backend-compliance/migrations/015_quality.sql
Normal file
36
backend-compliance/migrations/015_quality.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- Migration 015: AI Quality Metrics and Tests
|
||||
-- Tracking AI system quality metrics and test results
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_quality_metrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL DEFAULT '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'accuracy',
|
||||
-- accuracy | fairness | robustness | explainability | performance
|
||||
score NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
threshold NUMERIC(5,2) NOT NULL DEFAULT 80,
|
||||
trend TEXT DEFAULT 'stable',
|
||||
-- up | down | stable
|
||||
ai_system TEXT,
|
||||
last_measured TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_metrics_tenant ON compliance_quality_metrics(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_quality_tests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL DEFAULT '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
-- passed | failed | warning | pending
|
||||
duration TEXT,
|
||||
ai_system TEXT,
|
||||
details TEXT,
|
||||
last_run TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_tests_tenant ON compliance_quality_tests(tenant_id);
|
||||
43
backend-compliance/migrations/016_notfallplan_incidents.sql
Normal file
43
backend-compliance/migrations/016_notfallplan_incidents.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Migration 016: Notfallplan Incidents and Melde-Templates
|
||||
-- Extends Notfallplan module with incident register and template management
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_notfallplan_incidents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
detected_by TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'detected',
|
||||
-- detected | classified | assessed | reported | not_reportable | closed
|
||||
severity TEXT NOT NULL DEFAULT 'medium',
|
||||
-- low | medium | high | critical
|
||||
affected_data_categories JSONB DEFAULT '[]'::jsonb,
|
||||
estimated_affected_persons INTEGER DEFAULT 0,
|
||||
measures JSONB DEFAULT '[]'::jsonb,
|
||||
art34_required BOOLEAN DEFAULT FALSE,
|
||||
art34_justification TEXT,
|
||||
reported_to_authority_at TIMESTAMPTZ,
|
||||
notified_affected_at TIMESTAMPTZ,
|
||||
closed_at TIMESTAMPTZ,
|
||||
closed_by TEXT,
|
||||
lessons_learned TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_tenant ON compliance_notfallplan_incidents(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_status ON compliance_notfallplan_incidents(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_notfallplan_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||||
type TEXT NOT NULL DEFAULT 'art33',
|
||||
-- art33 | art34
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_tenant ON compliance_notfallplan_templates(tenant_id);
|
||||
45
backend-compliance/migrations/017_loeschfristen.sql
Normal file
45
backend-compliance/migrations/017_loeschfristen.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Migration 017: Loeschfristen (Retention Policies)
|
||||
-- Full retention policy management with legal holds and storage locations
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_loeschfristen (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL DEFAULT '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
policy_id TEXT, -- "LF-2026-001"
|
||||
data_object_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
affected_groups JSONB DEFAULT '[]'::jsonb,
|
||||
data_categories JSONB DEFAULT '[]'::jsonb,
|
||||
primary_purpose TEXT,
|
||||
deletion_trigger TEXT NOT NULL DEFAULT 'PURPOSE_END',
|
||||
-- PURPOSE_END | RETENTION_DRIVER | LEGAL_HOLD
|
||||
retention_driver TEXT,
|
||||
-- AO_147 | HGB_257 | USTG_14B | BGB_195 | ARBZG_16 | AGG_15 | BDSG_35 | BSIG | CUSTOM
|
||||
retention_driver_detail TEXT,
|
||||
retention_duration INTEGER,
|
||||
retention_unit TEXT, -- DAYS | MONTHS | YEARS
|
||||
retention_description TEXT,
|
||||
start_event TEXT,
|
||||
has_active_legal_hold BOOLEAN DEFAULT FALSE,
|
||||
legal_holds JSONB DEFAULT '[]'::jsonb,
|
||||
storage_locations JSONB DEFAULT '[]'::jsonb,
|
||||
deletion_method TEXT DEFAULT 'MANUAL_REVIEW_DELETE',
|
||||
deletion_method_detail TEXT,
|
||||
responsible_role TEXT,
|
||||
responsible_person TEXT,
|
||||
release_process TEXT,
|
||||
linked_vvt_activity_ids JSONB DEFAULT '[]'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
-- DRAFT | ACTIVE | REVIEW_NEEDED | ARCHIVED
|
||||
last_review_date TIMESTAMPTZ,
|
||||
next_review_date TIMESTAMPTZ,
|
||||
review_interval TEXT DEFAULT 'ANNUAL',
|
||||
-- QUARTERLY | SEMI_ANNUAL | ANNUAL
|
||||
tags JSONB DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_loeschfristen_tenant ON compliance_loeschfristen(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_loeschfristen_status ON compliance_loeschfristen(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_loeschfristen_driver ON compliance_loeschfristen(retention_driver);
|
||||
CREATE INDEX IF NOT EXISTS idx_loeschfristen_review ON compliance_loeschfristen(next_review_date) WHERE next_review_date IS NOT NULL;
|
||||
630
backend-compliance/tests/test_loeschfristen_routes.py
Normal file
630
backend-compliance/tests/test_loeschfristen_routes.py
Normal file
@@ -0,0 +1,630 @@
|
||||
"""Tests for Loeschfristen routes and schemas (loeschfristen_routes.py)."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
from datetime import datetime
|
||||
|
||||
from compliance.api.loeschfristen_routes import (
|
||||
LoeschfristCreate,
|
||||
LoeschfristUpdate,
|
||||
StatusUpdate,
|
||||
_row_to_dict,
|
||||
_get_tenant_id,
|
||||
DEFAULT_TENANT_ID,
|
||||
JSONB_FIELDS,
|
||||
router,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
DEFAULT_TENANT = DEFAULT_TENANT_ID # "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
POLICY_ID = "ffffffff-0001-0001-0001-000000000001"
|
||||
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
def make_policy_row(overrides=None):
|
||||
data = {
|
||||
"id": POLICY_ID,
|
||||
"tenant_id": DEFAULT_TENANT,
|
||||
"policy_id": "LF-2024-001",
|
||||
"data_object_name": "Kundendaten",
|
||||
"description": "Kundendaten Loeschfrist",
|
||||
"affected_groups": [],
|
||||
"data_categories": [],
|
||||
"primary_purpose": "Vertrag",
|
||||
"deletion_trigger": "PURPOSE_END",
|
||||
"retention_driver": "HGB_257",
|
||||
"retention_driver_detail": None,
|
||||
"retention_duration": 10,
|
||||
"retention_unit": "YEARS",
|
||||
"retention_description": None,
|
||||
"start_event": None,
|
||||
"has_active_legal_hold": False,
|
||||
"legal_holds": [],
|
||||
"storage_locations": [],
|
||||
"deletion_method": "MANUAL_REVIEW_DELETE",
|
||||
"deletion_method_detail": None,
|
||||
"responsible_role": None,
|
||||
"responsible_person": None,
|
||||
"release_process": None,
|
||||
"linked_vvt_activity_ids": [],
|
||||
"status": "DRAFT",
|
||||
"last_review_date": None,
|
||||
"next_review_date": None,
|
||||
"review_interval": "ANNUAL",
|
||||
"tags": [],
|
||||
"created_at": datetime(2024, 1, 1),
|
||||
"updated_at": datetime(2024, 1, 1),
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
row = MagicMock()
|
||||
row._mapping = data
|
||||
return row
|
||||
|
||||
|
||||
def make_stats_row(overrides=None):
|
||||
data = {
|
||||
"total": 0,
|
||||
"active": 0,
|
||||
"draft": 0,
|
||||
"review_needed": 0,
|
||||
"archived": 0,
|
||||
"legal_holds_count": 0,
|
||||
"overdue_reviews": 0,
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
row = MagicMock()
|
||||
row._mapping = data
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
from classroom_engine.database import get_db
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
yield db
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper / Utility Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestRowToDict:
|
||||
def test_converts_datetime_to_isoformat(self):
|
||||
row = make_policy_row({"created_at": datetime(2024, 6, 1, 12, 0, 0)})
|
||||
result = _row_to_dict(row)
|
||||
assert result["created_at"] == "2024-06-01T12:00:00"
|
||||
|
||||
def test_converts_none_datetime_remains_none(self):
|
||||
row = make_policy_row({"next_review_date": None})
|
||||
result = _row_to_dict(row)
|
||||
assert result["next_review_date"] is None
|
||||
|
||||
def test_preserves_string_values(self):
|
||||
row = make_policy_row({"data_object_name": "Mitarbeiterdaten"})
|
||||
result = _row_to_dict(row)
|
||||
assert result["data_object_name"] == "Mitarbeiterdaten"
|
||||
|
||||
def test_preserves_list_values(self):
|
||||
row = make_policy_row({"tags": ["dsgvo", "hgb"]})
|
||||
result = _row_to_dict(row)
|
||||
assert result["tags"] == ["dsgvo", "hgb"]
|
||||
|
||||
def test_preserves_int_values(self):
|
||||
row = make_policy_row({"retention_duration": 7})
|
||||
result = _row_to_dict(row)
|
||||
assert result["retention_duration"] == 7
|
||||
|
||||
|
||||
class TestGetTenantId:
|
||||
def test_valid_uuid_is_returned(self):
|
||||
assert _get_tenant_id("9282a473-5c95-4b3a-bf78-0ecc0ec71d3e") == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
def test_invalid_uuid_returns_default(self):
|
||||
assert _get_tenant_id("not-a-uuid") == DEFAULT_TENANT_ID
|
||||
|
||||
def test_none_returns_default(self):
|
||||
assert _get_tenant_id(None) == DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
class TestJsonbFields:
|
||||
def test_jsonb_fields_set(self):
|
||||
expected = {"affected_groups", "data_categories", "legal_holds",
|
||||
"storage_locations", "linked_vvt_activity_ids", "tags"}
|
||||
assert JSONB_FIELDS == expected
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — LoeschfristCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestLoeschfristCreate:
|
||||
def test_minimal_requires_data_object_name(self):
|
||||
obj = LoeschfristCreate(data_object_name="Kundendaten")
|
||||
assert obj.data_object_name == "Kundendaten"
|
||||
assert obj.deletion_trigger == "PURPOSE_END"
|
||||
assert obj.status == "DRAFT"
|
||||
assert obj.has_active_legal_hold is False
|
||||
|
||||
def test_full_object(self):
|
||||
obj = LoeschfristCreate(
|
||||
data_object_name="Mitarbeiterdaten",
|
||||
description="HR-Daten",
|
||||
primary_purpose="Arbeitsvertrag",
|
||||
retention_driver="AO_147",
|
||||
retention_duration=6,
|
||||
retention_unit="YEARS",
|
||||
status="ACTIVE",
|
||||
tags=["hr", "personal"],
|
||||
data_categories=["name", "address"],
|
||||
)
|
||||
assert obj.retention_duration == 6
|
||||
assert obj.retention_unit == "YEARS"
|
||||
assert obj.status == "ACTIVE"
|
||||
assert len(obj.tags) == 2
|
||||
|
||||
def test_missing_data_object_name_raises_validation_error(self):
|
||||
import pydantic
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
LoeschfristCreate()
|
||||
|
||||
def test_optional_fields_default_to_none(self):
|
||||
obj = LoeschfristCreate(data_object_name="Test")
|
||||
assert obj.description is None
|
||||
assert obj.retention_duration is None
|
||||
assert obj.responsible_role is None
|
||||
assert obj.policy_id is None
|
||||
|
||||
|
||||
class TestLoeschfristUpdate:
|
||||
def test_empty_update(self):
|
||||
obj = LoeschfristUpdate()
|
||||
data = obj.model_dump(exclude_unset=True)
|
||||
assert data == {}
|
||||
|
||||
def test_partial_update_status(self):
|
||||
obj = LoeschfristUpdate(status="ACTIVE")
|
||||
data = obj.model_dump(exclude_unset=True)
|
||||
assert data == {"status": "ACTIVE"}
|
||||
|
||||
def test_partial_update_multiple_fields(self):
|
||||
obj = LoeschfristUpdate(
|
||||
data_object_name="Neuer Name",
|
||||
retention_duration=5,
|
||||
retention_unit="YEARS",
|
||||
)
|
||||
data = obj.model_dump(exclude_unset=True)
|
||||
assert data["data_object_name"] == "Neuer Name"
|
||||
assert data["retention_duration"] == 5
|
||||
assert "status" not in data
|
||||
|
||||
|
||||
class TestStatusUpdateSchema:
|
||||
def test_valid_status(self):
|
||||
obj = StatusUpdate(status="ACTIVE")
|
||||
assert obj.status == "ACTIVE"
|
||||
|
||||
def test_all_valid_statuses(self):
|
||||
for s in ("DRAFT", "ACTIVE", "REVIEW_NEEDED", "ARCHIVED"):
|
||||
obj = StatusUpdate(status=s)
|
||||
assert obj.status == s
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /loeschfristen — List
|
||||
# =============================================================================
|
||||
|
||||
class TestListLoeschfristen:
|
||||
def test_list_returns_empty(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = MagicMock(__getitem__=lambda s, i: 0)
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
# Two execute calls: COUNT then SELECT
|
||||
count_result = MagicMock()
|
||||
count_result.__getitem__ = lambda s, i: 0
|
||||
list_result = MagicMock()
|
||||
list_result.fetchall.return_value = []
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=lambda: count_result),
|
||||
list_result,
|
||||
]
|
||||
resp = client.get("/loeschfristen")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "policies" in body
|
||||
assert "total" in body
|
||||
|
||||
def test_list_returns_policies(self, mock_db):
|
||||
count_result = MagicMock()
|
||||
count_result.__getitem__ = lambda s, i: 1
|
||||
policy_row = make_policy_row()
|
||||
list_result = MagicMock()
|
||||
list_result.fetchall.return_value = [policy_row]
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=lambda: count_result),
|
||||
list_result,
|
||||
]
|
||||
resp = client.get("/loeschfristen")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["policies"]) == 1
|
||||
assert body["policies"][0]["data_object_name"] == "Kundendaten"
|
||||
|
||||
def test_list_filter_by_status(self, mock_db):
|
||||
count_result = MagicMock()
|
||||
count_result.__getitem__ = lambda s, i: 0
|
||||
list_result = MagicMock()
|
||||
list_result.fetchall.return_value = []
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=lambda: count_result),
|
||||
list_result,
|
||||
]
|
||||
resp = client.get("/loeschfristen?status=ACTIVE")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_list_filter_by_retention_driver(self, mock_db):
|
||||
count_result = MagicMock()
|
||||
count_result.__getitem__ = lambda s, i: 0
|
||||
list_result = MagicMock()
|
||||
list_result.fetchall.return_value = []
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=lambda: count_result),
|
||||
list_result,
|
||||
]
|
||||
resp = client.get("/loeschfristen?retention_driver=HGB_257")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_list_filter_by_search(self, mock_db):
|
||||
count_result = MagicMock()
|
||||
count_result.__getitem__ = lambda s, i: 0
|
||||
list_result = MagicMock()
|
||||
list_result.fetchall.return_value = []
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=lambda: count_result),
|
||||
list_result,
|
||||
]
|
||||
resp = client.get("/loeschfristen?search=Kunden")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_list_uses_default_tenant(self, mock_db):
|
||||
count_result = MagicMock()
|
||||
count_result.__getitem__ = lambda s, i: 0
|
||||
list_result = MagicMock()
|
||||
list_result.fetchall.return_value = []
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=lambda: count_result),
|
||||
list_result,
|
||||
]
|
||||
client.get("/loeschfristen")
|
||||
# First call is the COUNT query
|
||||
first_call_params = mock_db.execute.call_args_list[0][0][1]
|
||||
assert first_call_params["tenant_id"] == DEFAULT_TENANT
|
||||
|
||||
def test_list_pagination_params(self, mock_db):
|
||||
count_result = MagicMock()
|
||||
count_result.__getitem__ = lambda s, i: 0
|
||||
list_result = MagicMock()
|
||||
list_result.fetchall.return_value = []
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=lambda: count_result),
|
||||
list_result,
|
||||
]
|
||||
resp = client.get("/loeschfristen?limit=10&offset=20")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /loeschfristen/stats
|
||||
# =============================================================================
|
||||
|
||||
class TestGetLoeschfristenStats:
|
||||
def test_stats_returns_all_keys(self, mock_db):
|
||||
stats_row = make_stats_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = stats_row
|
||||
resp = client.get("/loeschfristen/stats")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
for key in ("total", "active", "draft", "review_needed", "archived",
|
||||
"legal_holds_count", "overdue_reviews"):
|
||||
assert key in body, f"Missing key: {key}"
|
||||
|
||||
def test_stats_returns_correct_counts(self, mock_db):
|
||||
stats_row = make_stats_row({
|
||||
"total": 10,
|
||||
"active": 4,
|
||||
"draft": 3,
|
||||
"review_needed": 2,
|
||||
"archived": 1,
|
||||
"legal_holds_count": 1,
|
||||
"overdue_reviews": 0,
|
||||
})
|
||||
mock_db.execute.return_value.fetchone.return_value = stats_row
|
||||
resp = client.get("/loeschfristen/stats")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 10
|
||||
assert body["active"] == 4
|
||||
assert body["draft"] == 3
|
||||
assert body["review_needed"] == 2
|
||||
assert body["archived"] == 1
|
||||
assert body["legal_holds_count"] == 1
|
||||
|
||||
def test_stats_all_zeros_when_no_data(self, mock_db):
|
||||
stats_row = make_stats_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = stats_row
|
||||
resp = client.get("/loeschfristen/stats")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
for key in ("total", "active", "draft", "review_needed", "archived",
|
||||
"legal_holds_count", "overdue_reviews"):
|
||||
assert body[key] == 0
|
||||
|
||||
def test_stats_uses_default_tenant(self, mock_db):
|
||||
stats_row = make_stats_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = stats_row
|
||||
client.get("/loeschfristen/stats")
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == DEFAULT_TENANT
|
||||
|
||||
def test_stats_with_valid_tenant_header(self, mock_db):
|
||||
stats_row = make_stats_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = stats_row
|
||||
client.get(
|
||||
"/loeschfristen/stats",
|
||||
headers={"x-tenant-id": "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"},
|
||||
)
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /loeschfristen
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateLoeschfrist:
|
||||
def test_create_minimal(self, mock_db):
|
||||
row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.post("/loeschfristen", json={"data_object_name": "Kundendaten"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["data_object_name"] == "Kundendaten"
|
||||
|
||||
def test_create_missing_data_object_name_returns_422(self, mock_db):
|
||||
resp = client.post("/loeschfristen", json={"description": "No name"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_full_payload(self, mock_db):
|
||||
row = make_policy_row({
|
||||
"data_object_name": "Mitarbeiterdaten",
|
||||
"status": "ACTIVE",
|
||||
"retention_duration": 6,
|
||||
"retention_unit": "YEARS",
|
||||
})
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.post("/loeschfristen", json={
|
||||
"data_object_name": "Mitarbeiterdaten",
|
||||
"description": "HR-Datensatz",
|
||||
"retention_driver": "AO_147",
|
||||
"retention_duration": 6,
|
||||
"retention_unit": "YEARS",
|
||||
"status": "ACTIVE",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["status"] == "ACTIVE"
|
||||
assert data["retention_duration"] == 6
|
||||
|
||||
def test_create_commits(self, mock_db):
|
||||
row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.post("/loeschfristen", json={"data_object_name": "X"})
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_uses_default_tenant(self, mock_db):
|
||||
row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.post("/loeschfristen", json={"data_object_name": "X"})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == DEFAULT_TENANT
|
||||
|
||||
def test_create_jsonb_fields_are_json_encoded(self, mock_db):
|
||||
row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.post("/loeschfristen", json={
|
||||
"data_object_name": "Test",
|
||||
"tags": ["a", "b"],
|
||||
"data_categories": ["name"],
|
||||
})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
import json
|
||||
# JSONB fields must be JSON strings for CAST
|
||||
assert json.loads(call_params["tags"]) == ["a", "b"]
|
||||
assert json.loads(call_params["data_categories"]) == ["name"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /loeschfristen/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestGetLoeschfrist:
|
||||
def test_get_existing_policy(self, mock_db):
|
||||
row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.get(f"/loeschfristen/{POLICY_ID}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data_object_name"] == "Kundendaten"
|
||||
|
||||
def test_get_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = None
|
||||
resp = client.get(f"/loeschfristen/{UNKNOWN_ID}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_passes_id_and_tenant(self, mock_db):
|
||||
row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.get(f"/loeschfristen/{POLICY_ID}")
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["id"] == POLICY_ID
|
||||
assert call_params["tenant_id"] == DEFAULT_TENANT
|
||||
|
||||
def test_get_all_fields_present(self, mock_db):
|
||||
row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.get(f"/loeschfristen/{POLICY_ID}")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
for field in ("id", "tenant_id", "data_object_name", "status",
|
||||
"retention_duration", "retention_unit", "created_at", "updated_at"):
|
||||
assert field in body, f"Missing field: {field}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUT /loeschfristen/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateLoeschfrist:
|
||||
def test_update_success(self, mock_db):
|
||||
updated_row = make_policy_row({"data_object_name": "Neuer Name"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
resp = client.put(f"/loeschfristen/{POLICY_ID}", json={"data_object_name": "Neuer Name"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data_object_name"] == "Neuer Name"
|
||||
|
||||
def test_update_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = None
|
||||
resp = client.put(f"/loeschfristen/{UNKNOWN_ID}", json={"data_object_name": "X"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_empty_body_returns_400(self, mock_db):
|
||||
resp = client.put(f"/loeschfristen/{POLICY_ID}", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_jsonb_field(self, mock_db):
|
||||
updated_row = make_policy_row({"tags": ["urgent"]})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
resp = client.put(f"/loeschfristen/{POLICY_ID}", json={"tags": ["urgent"]})
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
import json
|
||||
assert json.loads(call_params["tags"]) == ["urgent"]
|
||||
|
||||
def test_update_sets_updated_at(self, mock_db):
|
||||
updated_row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
client.put(f"/loeschfristen/{POLICY_ID}", json={"status": "ACTIVE"})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert "updated_at" in call_params
|
||||
|
||||
def test_update_commits(self, mock_db):
|
||||
updated_row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
client.put(f"/loeschfristen/{POLICY_ID}", json={"status": "ACTIVE"})
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUT /loeschfristen/{id}/status
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateLoeschfristStatus:
|
||||
def test_valid_status_active(self, mock_db):
|
||||
updated_row = make_policy_row({"status": "ACTIVE"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "ACTIVE"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ACTIVE"
|
||||
|
||||
def test_valid_status_archived(self, mock_db):
|
||||
updated_row = make_policy_row({"status": "ARCHIVED"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "ARCHIVED"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_valid_status_review_needed(self, mock_db):
|
||||
updated_row = make_policy_row({"status": "REVIEW_NEEDED"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "REVIEW_NEEDED"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_valid_status_draft(self, mock_db):
|
||||
updated_row = make_policy_row({"status": "DRAFT"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "DRAFT"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_invalid_status_returns_400(self, mock_db):
|
||||
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "INVALID_STATUS"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_status_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = None
|
||||
resp = client.put(f"/loeschfristen/{UNKNOWN_ID}/status", json={"status": "ACTIVE"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_status_update_commits(self, mock_db):
|
||||
updated_row = make_policy_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "ACTIVE"})
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_status_update_passes_correct_params(self, mock_db):
|
||||
updated_row = make_policy_row({"status": "ACTIVE"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "ACTIVE"})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["status"] == "ACTIVE"
|
||||
assert call_params["id"] == POLICY_ID
|
||||
assert call_params["tenant_id"] == DEFAULT_TENANT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DELETE /loeschfristen/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteLoeschfrist:
|
||||
def test_delete_success_returns_204(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
resp = client.delete(f"/loeschfristen/{POLICY_ID}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_delete_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 0
|
||||
resp = client.delete(f"/loeschfristen/{UNKNOWN_ID}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_commits(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
client.delete(f"/loeschfristen/{POLICY_ID}")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_commits_before_rowcount_check(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 0
|
||||
client.delete(f"/loeschfristen/{UNKNOWN_ID}")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_passes_correct_id_and_tenant(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
client.delete(f"/loeschfristen/{POLICY_ID}")
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["id"] == POLICY_ID
|
||||
assert call_params["tenant_id"] == DEFAULT_TENANT
|
||||
|
||||
def test_delete_with_custom_tenant(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
client.delete(
|
||||
f"/loeschfristen/{POLICY_ID}",
|
||||
headers={"x-tenant-id": "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"},
|
||||
)
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
@@ -1,6 +1,14 @@
|
||||
"""Tests for Notfallplan routes and schemas (notfallplan_routes.py)."""
|
||||
"""Tests for Notfallplan routes and schemas (notfallplan_routes.py).
|
||||
|
||||
Covers existing schema tests plus the new incidents and templates HTTP endpoints.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
from datetime import datetime
|
||||
|
||||
from compliance.api.notfallplan_routes import (
|
||||
ContactCreate,
|
||||
ContactUpdate,
|
||||
@@ -8,11 +16,87 @@ from compliance.api.notfallplan_routes import (
|
||||
ScenarioUpdate,
|
||||
ChecklistCreate,
|
||||
ExerciseCreate,
|
||||
IncidentCreate,
|
||||
TemplateCreate,
|
||||
router,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
DEFAULT_TENANT = "default"
|
||||
INCIDENT_ID = "dddddddd-0001-0001-0001-000000000001"
|
||||
TEMPLATE_ID = "eeeeeeee-0001-0001-0001-000000000001"
|
||||
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — ContactCreate
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
def make_incident_row(overrides=None):
|
||||
data = {
|
||||
"id": INCIDENT_ID,
|
||||
"tenant_id": DEFAULT_TENANT,
|
||||
"title": "Test Incident",
|
||||
"description": "An incident occurred",
|
||||
"detected_at": datetime(2024, 1, 1),
|
||||
"detected_by": "System",
|
||||
"status": "detected",
|
||||
"severity": "medium",
|
||||
"affected_data_categories": [],
|
||||
"estimated_affected_persons": 0,
|
||||
"measures": [],
|
||||
"art34_required": False,
|
||||
"art34_justification": None,
|
||||
"reported_to_authority_at": None,
|
||||
"notified_affected_at": None,
|
||||
"closed_at": None,
|
||||
"closed_by": None,
|
||||
"lessons_learned": None,
|
||||
"created_at": datetime(2024, 1, 1),
|
||||
"updated_at": datetime(2024, 1, 1),
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
row = MagicMock()
|
||||
for k, v in data.items():
|
||||
setattr(row, k, v)
|
||||
row._mapping = data
|
||||
return row
|
||||
|
||||
|
||||
def make_template_row(overrides=None):
|
||||
data = {
|
||||
"id": TEMPLATE_ID,
|
||||
"tenant_id": DEFAULT_TENANT,
|
||||
"type": "art33",
|
||||
"title": "Art. 33 Template",
|
||||
"content": "Sehr geehrte Behoerde...",
|
||||
"created_at": datetime(2024, 1, 1),
|
||||
"updated_at": datetime(2024, 1, 1),
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
row = MagicMock()
|
||||
for k, v in data.items():
|
||||
setattr(row, k, v)
|
||||
row._mapping = data
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
from classroom_engine.database import get_db
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
yield db
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Existing Schema Tests — ContactCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestContactCreate:
|
||||
@@ -46,7 +130,7 @@ class TestContactCreate:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — ContactUpdate
|
||||
# Existing Schema Tests — ContactUpdate
|
||||
# =============================================================================
|
||||
|
||||
class TestContactUpdate:
|
||||
@@ -62,7 +146,7 @@ class TestContactUpdate:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — ScenarioCreate
|
||||
# Existing Schema Tests — ScenarioCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestScenarioCreate:
|
||||
@@ -100,7 +184,7 @@ class TestScenarioCreate:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — ScenarioUpdate
|
||||
# Existing Schema Tests — ScenarioUpdate
|
||||
# =============================================================================
|
||||
|
||||
class TestScenarioUpdate:
|
||||
@@ -121,7 +205,7 @@ class TestScenarioUpdate:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — ChecklistCreate
|
||||
# Existing Schema Tests — ChecklistCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestChecklistCreate:
|
||||
@@ -144,7 +228,7 @@ class TestChecklistCreate:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — ExerciseCreate
|
||||
# Existing Schema Tests — ExerciseCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestExerciseCreate:
|
||||
@@ -165,3 +249,558 @@ class TestExerciseCreate:
|
||||
assert req.outcome == "passed"
|
||||
assert len(req.participants) == 2
|
||||
assert req.notes == "Übung verlief planmäßig"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# New Schema Tests — IncidentCreate / TemplateCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestIncidentCreateSchema:
|
||||
def test_incident_create_minimal(self):
|
||||
inc = IncidentCreate(title="Breach")
|
||||
assert inc.title == "Breach"
|
||||
assert inc.status == "detected"
|
||||
assert inc.severity == "medium"
|
||||
assert inc.estimated_affected_persons == 0
|
||||
assert inc.art34_required is False
|
||||
assert inc.affected_data_categories == []
|
||||
assert inc.measures == []
|
||||
|
||||
def test_incident_create_full(self):
|
||||
inc = IncidentCreate(
|
||||
title="Big Breach",
|
||||
description="Ransomware attack",
|
||||
detected_by="SIEM",
|
||||
status="assessed",
|
||||
severity="critical",
|
||||
affected_data_categories=["personal", "health"],
|
||||
estimated_affected_persons=1000,
|
||||
measures=["isolation", "backup restore"],
|
||||
art34_required=True,
|
||||
art34_justification="High risk to data subjects",
|
||||
)
|
||||
assert inc.severity == "critical"
|
||||
assert inc.estimated_affected_persons == 1000
|
||||
assert len(inc.affected_data_categories) == 2
|
||||
assert inc.art34_required is True
|
||||
|
||||
def test_incident_create_serialization_excludes_none(self):
|
||||
inc = IncidentCreate(title="T")
|
||||
data = inc.model_dump(exclude_none=True)
|
||||
assert data["title"] == "T"
|
||||
assert "art34_justification" not in data
|
||||
assert "description" not in data
|
||||
|
||||
|
||||
class TestTemplateCreateSchema:
|
||||
def test_template_create_requires_title_content(self):
|
||||
t = TemplateCreate(title="T", content="C", type="art33")
|
||||
assert t.title == "T"
|
||||
assert t.content == "C"
|
||||
assert t.type == "art33"
|
||||
|
||||
def test_template_create_default_type(self):
|
||||
t = TemplateCreate(title="T", content="C")
|
||||
assert t.type == "art33"
|
||||
|
||||
def test_template_create_art34_type(self):
|
||||
t = TemplateCreate(title="Notification Letter", content="Dear...", type="art34")
|
||||
assert t.type == "art34"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Incidents — GET /notfallplan/incidents
|
||||
# =============================================================================
|
||||
|
||||
class TestListIncidents:
|
||||
def test_list_incidents_returns_empty_list(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/incidents")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_list_incidents_returns_one_incident(self, mock_db):
|
||||
row = make_incident_row()
|
||||
mock_db.execute.return_value.fetchall.return_value = [row]
|
||||
resp = client.get("/notfallplan/incidents")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["title"] == "Test Incident"
|
||||
assert data[0]["status"] == "detected"
|
||||
assert data[0]["severity"] == "medium"
|
||||
|
||||
def test_list_incidents_returns_multiple(self, mock_db):
|
||||
rows = [
|
||||
make_incident_row({"id": "id-1", "title": "Incident A"}),
|
||||
make_incident_row({"id": "id-2", "title": "Incident B"}),
|
||||
]
|
||||
mock_db.execute.return_value.fetchall.return_value = rows
|
||||
resp = client.get("/notfallplan/incidents")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
def test_list_incidents_filter_by_status(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/incidents?status=closed")
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params.get("status") == "closed"
|
||||
|
||||
def test_list_incidents_filter_by_severity(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/incidents?severity=high")
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params.get("severity") == "high"
|
||||
|
||||
def test_list_incidents_filter_combined(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/incidents?status=detected&severity=critical")
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params.get("status") == "detected"
|
||||
assert call_params.get("severity") == "critical"
|
||||
|
||||
def test_list_incidents_uses_default_tenant(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/incidents")
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params.get("tenant_id") == DEFAULT_TENANT
|
||||
|
||||
def test_list_incidents_custom_tenant_header(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/incidents", headers={"X-Tenant-ID": "my-tenant"})
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params.get("tenant_id") == "my-tenant"
|
||||
|
||||
def test_list_incidents_all_fields_present(self, mock_db):
|
||||
row = make_incident_row()
|
||||
mock_db.execute.return_value.fetchall.return_value = [row]
|
||||
resp = client.get("/notfallplan/incidents")
|
||||
item = resp.json()[0]
|
||||
expected_fields = (
|
||||
"id", "tenant_id", "title", "description", "detected_at",
|
||||
"detected_by", "status", "severity", "affected_data_categories",
|
||||
"estimated_affected_persons", "measures", "art34_required",
|
||||
"art34_justification", "reported_to_authority_at",
|
||||
"notified_affected_at", "closed_at", "closed_by",
|
||||
"lessons_learned", "created_at", "updated_at",
|
||||
)
|
||||
for field in expected_fields:
|
||||
assert field in item, f"Missing field: {field}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Incidents — POST /notfallplan/incidents
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateIncident:
|
||||
def test_create_incident_minimal(self, mock_db):
|
||||
row = make_incident_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.post("/notfallplan/incidents", json={"title": "New Incident"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["title"] == "Test Incident"
|
||||
|
||||
def test_create_incident_full_payload(self, mock_db):
|
||||
row = make_incident_row({
|
||||
"title": "Critical Breach",
|
||||
"description": "Database exposed",
|
||||
"detected_by": "SOC Team",
|
||||
"status": "assessed",
|
||||
"severity": "critical",
|
||||
"estimated_affected_persons": 500,
|
||||
})
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.post("/notfallplan/incidents", json={
|
||||
"title": "Critical Breach",
|
||||
"description": "Database exposed",
|
||||
"detected_by": "SOC Team",
|
||||
"status": "assessed",
|
||||
"severity": "critical",
|
||||
"estimated_affected_persons": 500,
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["severity"] == "critical"
|
||||
assert data["estimated_affected_persons"] == 500
|
||||
|
||||
def test_create_incident_missing_title_returns_422(self, mock_db):
|
||||
resp = client.post("/notfallplan/incidents", json={"description": "No title here"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_incident_default_status_passed_to_db(self, mock_db):
|
||||
row = make_incident_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.post("/notfallplan/incidents", json={"title": "T"})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["status"] == "detected"
|
||||
assert call_params["severity"] == "medium"
|
||||
|
||||
def test_create_incident_commits(self, mock_db):
|
||||
row = make_incident_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.post("/notfallplan/incidents", json={"title": "T"})
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_incident_with_art34_required(self, mock_db):
|
||||
row = make_incident_row({"art34_required": True, "art34_justification": "Hohe Risikobewertung"})
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.post("/notfallplan/incidents", json={
|
||||
"title": "High Risk",
|
||||
"art34_required": True,
|
||||
"art34_justification": "Hohe Risikobewertung",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["art34_required"] is True
|
||||
|
||||
def test_create_incident_passes_tenant_id(self, mock_db):
|
||||
row = make_incident_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.post("/notfallplan/incidents",
|
||||
json={"title": "T"},
|
||||
headers={"X-Tenant-ID": "custom-org"})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == "custom-org"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Incidents — PUT /notfallplan/incidents/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateIncident:
|
||||
def test_update_incident_success(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_incident_row({"status": "assessed"})
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "assessed"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "assessed"
|
||||
|
||||
def test_update_incident_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = None
|
||||
resp = client.put(f"/notfallplan/incidents/{UNKNOWN_ID}", json={"status": "closed"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_incident_empty_body_returns_400(self, mock_db):
|
||||
existing = MagicMock()
|
||||
mock_db.execute.return_value.fetchone.return_value = existing
|
||||
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_incident_status_to_reported_auto_sets_timestamp(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_incident_row({"status": "reported"})
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "reported"})
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert "reported_to_authority_at" in call_params
|
||||
|
||||
def test_update_incident_status_to_closed_auto_sets_closed_at(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_incident_row({"status": "closed"})
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "closed"})
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert "closed_at" in call_params
|
||||
|
||||
def test_update_incident_lessons_learned(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_incident_row({"lessons_learned": "Besseres Monitoring nötig"})
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
resp = client.put(
|
||||
f"/notfallplan/incidents/{INCIDENT_ID}",
|
||||
json={"lessons_learned": "Besseres Monitoring nötig"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["lessons_learned"] == "Besseres Monitoring nötig"
|
||||
|
||||
def test_update_incident_severity(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_incident_row({"severity": "high"})
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"severity": "high"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["severity"] == "high"
|
||||
|
||||
def test_update_incident_commits(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_incident_row()
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"severity": "low"})
|
||||
mock_db.commit.assert_called()
|
||||
|
||||
def test_update_incident_always_sets_updated_at(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_incident_row()
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"title": "Renamed"})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert "updated_at" in call_params
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Incidents — DELETE /notfallplan/incidents/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteIncident:
|
||||
def test_delete_incident_success_returns_204(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
resp = client.delete(f"/notfallplan/incidents/{INCIDENT_ID}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_delete_incident_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 0
|
||||
resp = client.delete(f"/notfallplan/incidents/{UNKNOWN_ID}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_incident_commits(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
client.delete(f"/notfallplan/incidents/{INCIDENT_ID}")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_incident_commits_even_when_not_found(self, mock_db):
|
||||
# Commit is called before the rowcount check in the implementation
|
||||
mock_db.execute.return_value.rowcount = 0
|
||||
client.delete(f"/notfallplan/incidents/{UNKNOWN_ID}")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_incident_passes_tenant_id(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
client.delete(
|
||||
f"/notfallplan/incidents/{INCIDENT_ID}",
|
||||
headers={"X-Tenant-ID": "acme"},
|
||||
)
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == "acme"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Templates — GET /notfallplan/templates
|
||||
# =============================================================================
|
||||
|
||||
class TestListTemplates:
|
||||
def test_list_templates_returns_empty_list(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/templates")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_list_templates_returns_one(self, mock_db):
|
||||
row = make_template_row()
|
||||
mock_db.execute.return_value.fetchall.return_value = [row]
|
||||
resp = client.get("/notfallplan/templates")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["title"] == "Art. 33 Template"
|
||||
assert data[0]["type"] == "art33"
|
||||
|
||||
def test_list_templates_returns_multiple(self, mock_db):
|
||||
rows = [
|
||||
make_template_row({"id": "id-1", "type": "art33", "title": "Meldung Art.33"}),
|
||||
make_template_row({"id": "id-2", "type": "art34", "title": "Meldung Art.34"}),
|
||||
]
|
||||
mock_db.execute.return_value.fetchall.return_value = rows
|
||||
resp = client.get("/notfallplan/templates")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
def test_list_templates_filter_by_type(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/templates?type=art34")
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params.get("type") == "art34"
|
||||
|
||||
def test_list_templates_without_type_no_type_param_sent(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
resp = client.get("/notfallplan/templates")
|
||||
assert resp.status_code == 200
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert "type" not in call_params
|
||||
|
||||
def test_list_templates_uses_default_tenant(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
client.get("/notfallplan/templates")
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == DEFAULT_TENANT
|
||||
|
||||
def test_list_templates_custom_tenant_header(self, mock_db):
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
client.get("/notfallplan/templates", headers={"X-Tenant-ID": "acme"})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == "acme"
|
||||
|
||||
def test_list_templates_all_fields_present(self, mock_db):
|
||||
row = make_template_row()
|
||||
mock_db.execute.return_value.fetchall.return_value = [row]
|
||||
resp = client.get("/notfallplan/templates")
|
||||
item = resp.json()[0]
|
||||
for field in ("id", "tenant_id", "type", "title", "content", "created_at", "updated_at"):
|
||||
assert field in item, f"Missing field: {field}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Templates — POST /notfallplan/templates
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateTemplate:
|
||||
def test_create_template_success(self, mock_db):
|
||||
row = make_template_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.post("/notfallplan/templates", json={
|
||||
"title": "Art. 33 Template",
|
||||
"content": "Sehr geehrte Behoerde...",
|
||||
"type": "art33",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["title"] == "Art. 33 Template"
|
||||
|
||||
def test_create_template_missing_title_returns_422(self, mock_db):
|
||||
resp = client.post("/notfallplan/templates", json={
|
||||
"content": "Some content",
|
||||
"type": "art33",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_template_missing_content_returns_422(self, mock_db):
|
||||
resp = client.post("/notfallplan/templates", json={
|
||||
"title": "Template",
|
||||
"type": "art33",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_template_missing_type_uses_default_art33(self, mock_db):
|
||||
row = make_template_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.post("/notfallplan/templates", json={"title": "T", "content": "C"})
|
||||
assert resp.status_code == 201
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["type"] == "art33"
|
||||
|
||||
def test_create_template_art34_type(self, mock_db):
|
||||
row = make_template_row({"type": "art34"})
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
resp = client.post("/notfallplan/templates", json={
|
||||
"title": "Art. 34 Notification",
|
||||
"content": "Sehr geehrte Betroffene...",
|
||||
"type": "art34",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["type"] == "art34"
|
||||
|
||||
def test_create_template_commits(self, mock_db):
|
||||
row = make_template_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.post("/notfallplan/templates", json={"title": "T", "content": "C", "type": "art33"})
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_template_passes_tenant_id(self, mock_db):
|
||||
row = make_template_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = row
|
||||
client.post(
|
||||
"/notfallplan/templates",
|
||||
json={"title": "T", "content": "C", "type": "art33"},
|
||||
headers={"X-Tenant-ID": "my-org"},
|
||||
)
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == "my-org"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Templates — PUT /notfallplan/templates/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateTemplate:
|
||||
def test_update_template_title_success(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_template_row({"title": "Updated Title"})
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "Updated Title"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Updated Title"
|
||||
|
||||
def test_update_template_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = None
|
||||
resp = client.put(f"/notfallplan/templates/{UNKNOWN_ID}", json={"title": "X"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_template_empty_body_returns_400(self, mock_db):
|
||||
existing = MagicMock()
|
||||
mock_db.execute.return_value.fetchone.return_value = existing
|
||||
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_template_content(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_template_row({"content": "New body text"})
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
resp = client.put(
|
||||
f"/notfallplan/templates/{TEMPLATE_ID}",
|
||||
json={"content": "New body text"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["content"] == "New body text"
|
||||
|
||||
def test_update_template_type(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_template_row({"type": "internal"})
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"type": "internal"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_update_template_commits(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_template_row()
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "New"})
|
||||
mock_db.commit.assert_called()
|
||||
|
||||
def test_update_template_sets_updated_at(self, mock_db):
|
||||
existing = MagicMock()
|
||||
updated_row = make_template_row()
|
||||
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
||||
client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "New"})
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert "updated_at" in call_params
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Templates — DELETE /notfallplan/templates/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteTemplate:
|
||||
def test_delete_template_success_returns_204(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
resp = client.delete(f"/notfallplan/templates/{TEMPLATE_ID}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_delete_template_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 0
|
||||
resp = client.delete(f"/notfallplan/templates/{UNKNOWN_ID}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_template_commits_on_success(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
client.delete(f"/notfallplan/templates/{TEMPLATE_ID}")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_template_commits_even_when_not_found(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 0
|
||||
client.delete(f"/notfallplan/templates/{UNKNOWN_ID}")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_template_passes_correct_tenant_id(self, mock_db):
|
||||
mock_db.execute.return_value.rowcount = 1
|
||||
client.delete(
|
||||
f"/notfallplan/templates/{TEMPLATE_ID}",
|
||||
headers={"X-Tenant-ID": "acme"},
|
||||
)
|
||||
call_params = mock_db.execute.call_args[0][1]
|
||||
assert call_params["tenant_id"] == "acme"
|
||||
|
||||
937
backend-compliance/tests/test_quality_routes.py
Normal file
937
backend-compliance/tests/test_quality_routes.py
Normal file
@@ -0,0 +1,937 @@
|
||||
"""Tests for AI Quality Metrics and Tests routes (quality_routes.py).
|
||||
|
||||
Covers:
|
||||
- Schema validation (MetricCreate, MetricUpdate, TestCreate, TestUpdate)
|
||||
- Helper functions (_row_to_dict, _get_tenant_id)
|
||||
- HTTP endpoints via FastAPI TestClient with mocked DB session
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.api.quality_routes import (
|
||||
router,
|
||||
MetricCreate,
|
||||
MetricUpdate,
|
||||
TestCreate,
|
||||
TestUpdate,
|
||||
_row_to_dict,
|
||||
_get_tenant_id,
|
||||
DEFAULT_TENANT_ID,
|
||||
)
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
# =============================================================================
|
||||
# TestClient Setup
|
||||
# =============================================================================
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
OTHER_TENANT = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
|
||||
METRIC_ID = "bbbbbbbb-0001-0001-0001-000000000001"
|
||||
TEST_ID = "cccccccc-0001-0001-0001-000000000001"
|
||||
|
||||
|
||||
def make_metric_row(overrides=None):
|
||||
data = {
|
||||
"id": METRIC_ID,
|
||||
"tenant_id": DEFAULT_TENANT,
|
||||
"name": "Test Metric",
|
||||
"category": "accuracy",
|
||||
"score": 85.0,
|
||||
"threshold": 80.0,
|
||||
"trend": "stable",
|
||||
"ai_system": None,
|
||||
"last_measured": datetime(2024, 1, 1),
|
||||
"created_at": datetime(2024, 1, 1),
|
||||
"updated_at": datetime(2024, 1, 1),
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
row = MagicMock()
|
||||
row._mapping = data
|
||||
return row
|
||||
|
||||
|
||||
def make_test_row(overrides=None):
|
||||
data = {
|
||||
"id": TEST_ID,
|
||||
"tenant_id": DEFAULT_TENANT,
|
||||
"name": "Test Run",
|
||||
"status": "passed",
|
||||
"duration": "1.2s",
|
||||
"ai_system": None,
|
||||
"details": None,
|
||||
"last_run": datetime(2024, 1, 1),
|
||||
"created_at": datetime(2024, 1, 1),
|
||||
"updated_at": datetime(2024, 1, 1),
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
row = MagicMock()
|
||||
row._mapping = data
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = MagicMock()
|
||||
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
yield db
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — MetricCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestMetricCreate:
|
||||
def test_minimal_valid(self):
|
||||
req = MetricCreate(name="Accuracy Score")
|
||||
assert req.name == "Accuracy Score"
|
||||
assert req.category == "accuracy"
|
||||
assert req.score == 0.0
|
||||
assert req.threshold == 80.0
|
||||
assert req.trend == "stable"
|
||||
assert req.ai_system is None
|
||||
assert req.last_measured is None
|
||||
|
||||
def test_full_values(self):
|
||||
ts = datetime(2026, 3, 1)
|
||||
req = MetricCreate(
|
||||
name="Fairness Score",
|
||||
category="fairness",
|
||||
score=92.5,
|
||||
threshold=85.0,
|
||||
trend="improving",
|
||||
ai_system="RecSys-v2",
|
||||
last_measured=ts,
|
||||
)
|
||||
assert req.name == "Fairness Score"
|
||||
assert req.category == "fairness"
|
||||
assert req.score == 92.5
|
||||
assert req.trend == "improving"
|
||||
assert req.ai_system == "RecSys-v2"
|
||||
assert req.last_measured == ts
|
||||
|
||||
def test_serialization_excludes_none(self):
|
||||
req = MetricCreate(name="Drift Score", score=75.0)
|
||||
data = req.model_dump(exclude_none=True)
|
||||
assert data["name"] == "Drift Score"
|
||||
assert data["score"] == 75.0
|
||||
assert "ai_system" not in data
|
||||
assert "last_measured" not in data
|
||||
|
||||
def test_default_trend_stable(self):
|
||||
req = MetricCreate(name="Test")
|
||||
assert req.trend == "stable"
|
||||
|
||||
def test_default_score_zero(self):
|
||||
req = MetricCreate(name="Test")
|
||||
assert req.score == 0.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — MetricUpdate
|
||||
# =============================================================================
|
||||
|
||||
class TestMetricUpdate:
|
||||
def test_empty_update(self):
|
||||
req = MetricUpdate()
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data == {}
|
||||
|
||||
def test_partial_score_update(self):
|
||||
req = MetricUpdate(score=95.0)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data == {"score": 95.0}
|
||||
|
||||
def test_trend_and_threshold_update(self):
|
||||
req = MetricUpdate(trend="declining", threshold=90.0)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data["trend"] == "declining"
|
||||
assert data["threshold"] == 90.0
|
||||
assert "name" not in data
|
||||
|
||||
def test_full_update(self):
|
||||
req = MetricUpdate(
|
||||
name="New Name",
|
||||
category="robustness",
|
||||
score=88.0,
|
||||
threshold=85.0,
|
||||
trend="improving",
|
||||
ai_system="ModelB",
|
||||
)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert len(data) == 6
|
||||
assert data["category"] == "robustness"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — TestCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestTestCreate:
|
||||
def test_minimal_valid(self):
|
||||
req = TestCreate(name="Bias Detection Test")
|
||||
assert req.name == "Bias Detection Test"
|
||||
assert req.status == "pending"
|
||||
assert req.duration is None
|
||||
assert req.ai_system is None
|
||||
assert req.details is None
|
||||
assert req.last_run is None
|
||||
|
||||
def test_full_values(self):
|
||||
ts = datetime(2026, 3, 1, 12, 0, 0)
|
||||
req = TestCreate(
|
||||
name="Accuracy Test Suite",
|
||||
status="passed",
|
||||
duration="3.45s",
|
||||
ai_system="ClassifierV3",
|
||||
details="All 500 samples passed",
|
||||
last_run=ts,
|
||||
)
|
||||
assert req.status == "passed"
|
||||
assert req.duration == "3.45s"
|
||||
assert req.ai_system == "ClassifierV3"
|
||||
assert req.details == "All 500 samples passed"
|
||||
assert req.last_run == ts
|
||||
|
||||
def test_failed_status(self):
|
||||
req = TestCreate(name="Fairness Check", status="failed")
|
||||
assert req.status == "failed"
|
||||
|
||||
def test_serialization_excludes_none(self):
|
||||
req = TestCreate(name="Quick Test", status="passed")
|
||||
data = req.model_dump(exclude_none=True)
|
||||
assert "duration" not in data
|
||||
assert "ai_system" not in data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — TestUpdate
|
||||
# =============================================================================
|
||||
|
||||
class TestTestUpdate:
|
||||
def test_empty_update(self):
|
||||
req = TestUpdate()
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data == {}
|
||||
|
||||
def test_status_update(self):
|
||||
req = TestUpdate(status="failed")
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data == {"status": "failed"}
|
||||
|
||||
def test_duration_and_details_update(self):
|
||||
req = TestUpdate(duration="10.5s", details="Timeout on 3 samples")
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data["duration"] == "10.5s"
|
||||
assert data["details"] == "Timeout on 3 samples"
|
||||
assert "name" not in data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Tests — _row_to_dict
|
||||
# =============================================================================
|
||||
|
||||
class TestRowToDict:
|
||||
def test_basic_metric_conversion(self):
|
||||
row = make_metric_row()
|
||||
result = _row_to_dict(row)
|
||||
assert result["id"] == METRIC_ID
|
||||
assert result["name"] == "Test Metric"
|
||||
assert result["score"] == 85.0
|
||||
assert result["threshold"] == 80.0
|
||||
|
||||
def test_datetime_serialized(self):
|
||||
ts = datetime(2024, 6, 15, 9, 0, 0)
|
||||
row = make_metric_row({"created_at": ts, "last_measured": ts})
|
||||
result = _row_to_dict(row)
|
||||
assert result["created_at"] == ts.isoformat()
|
||||
assert result["last_measured"] == ts.isoformat()
|
||||
|
||||
def test_none_values_preserved(self):
|
||||
row = make_metric_row()
|
||||
result = _row_to_dict(row)
|
||||
assert result["ai_system"] is None
|
||||
|
||||
def test_uuid_converted_to_string(self):
|
||||
import uuid
|
||||
uid = uuid.UUID(DEFAULT_TENANT)
|
||||
row = MagicMock()
|
||||
row._mapping = {"id": uid, "tenant_id": uid}
|
||||
result = _row_to_dict(row)
|
||||
assert result["id"] == str(uid)
|
||||
|
||||
def test_numeric_fields_unchanged(self):
|
||||
row = MagicMock()
|
||||
row._mapping = {"score": 92.5, "threshold": 80.0, "count": 10}
|
||||
result = _row_to_dict(row)
|
||||
assert result["score"] == 92.5
|
||||
assert result["threshold"] == 80.0
|
||||
assert result["count"] == 10
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Tests — _get_tenant_id
|
||||
# =============================================================================
|
||||
|
||||
class TestGetTenantId:
|
||||
def test_valid_uuid_returned(self):
|
||||
result = _get_tenant_id(x_tenant_id=DEFAULT_TENANT)
|
||||
assert result == DEFAULT_TENANT
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = _get_tenant_id(x_tenant_id=None)
|
||||
assert result == DEFAULT_TENANT_ID
|
||||
|
||||
def test_invalid_uuid_returns_default(self):
|
||||
result = _get_tenant_id(x_tenant_id="invalid-uuid")
|
||||
assert result == DEFAULT_TENANT_ID
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
result = _get_tenant_id(x_tenant_id="")
|
||||
assert result == DEFAULT_TENANT_ID
|
||||
|
||||
def test_other_valid_tenant(self):
|
||||
result = _get_tenant_id(x_tenant_id=OTHER_TENANT)
|
||||
assert result == OTHER_TENANT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — GET /quality/stats
|
||||
# =============================================================================
|
||||
|
||||
class TestGetQualityStats:
|
||||
def test_stats_all_zeros(self, mock_db):
|
||||
metrics_row = MagicMock()
|
||||
metrics_row.total_metrics = 0
|
||||
metrics_row.avg_score = 0
|
||||
metrics_row.metrics_above_threshold = 0
|
||||
|
||||
tests_row = MagicMock()
|
||||
tests_row.passed = 0
|
||||
tests_row.failed = 0
|
||||
tests_row.warning = 0
|
||||
tests_row.total = 0
|
||||
|
||||
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
|
||||
|
||||
response = client.get("/quality/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_metrics"] == 0
|
||||
assert data["avg_score"] == 0.0
|
||||
assert data["metrics_above_threshold"] == 0
|
||||
assert data["passed"] == 0
|
||||
assert data["failed"] == 0
|
||||
assert data["warning"] == 0
|
||||
assert data["total_tests"] == 0
|
||||
|
||||
def test_stats_with_data(self, mock_db):
|
||||
metrics_row = MagicMock()
|
||||
metrics_row.total_metrics = 5
|
||||
metrics_row.avg_score = 87.4
|
||||
metrics_row.metrics_above_threshold = 4
|
||||
|
||||
tests_row = MagicMock()
|
||||
tests_row.passed = 10
|
||||
tests_row.failed = 2
|
||||
tests_row.warning = 1
|
||||
tests_row.total = 13
|
||||
|
||||
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
|
||||
|
||||
response = client.get("/quality/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_metrics"] == 5
|
||||
assert data["avg_score"] == 87.4
|
||||
assert data["metrics_above_threshold"] == 4
|
||||
assert data["passed"] == 10
|
||||
assert data["failed"] == 2
|
||||
assert data["warning"] == 1
|
||||
assert data["total_tests"] == 13
|
||||
|
||||
def test_stats_none_values_become_zero(self, mock_db):
|
||||
metrics_row = MagicMock()
|
||||
metrics_row.total_metrics = None
|
||||
metrics_row.avg_score = None
|
||||
metrics_row.metrics_above_threshold = None
|
||||
|
||||
tests_row = MagicMock()
|
||||
tests_row.passed = None
|
||||
tests_row.failed = None
|
||||
tests_row.warning = None
|
||||
tests_row.total = None
|
||||
|
||||
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
|
||||
|
||||
response = client.get("/quality/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_metrics"] == 0
|
||||
assert data["avg_score"] == 0.0
|
||||
assert data["total_tests"] == 0
|
||||
|
||||
def test_stats_with_tenant_header(self, mock_db):
|
||||
metrics_row = MagicMock()
|
||||
metrics_row.total_metrics = 2
|
||||
metrics_row.avg_score = 90.0
|
||||
metrics_row.metrics_above_threshold = 2
|
||||
|
||||
tests_row = MagicMock()
|
||||
tests_row.passed = 5
|
||||
tests_row.failed = 0
|
||||
tests_row.warning = 0
|
||||
tests_row.total = 5
|
||||
|
||||
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
|
||||
|
||||
response = client.get(
|
||||
"/quality/stats",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total_metrics"] == 2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — GET /quality/metrics
|
||||
# =============================================================================
|
||||
|
||||
class TestListMetrics:
|
||||
def test_empty_list(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = []
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/metrics")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["metrics"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_with_data(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 2
|
||||
m1 = make_metric_row()
|
||||
m2 = make_metric_row({"id": "bbbbbbbb-0002-0002-0002-000000000002", "name": "Second Metric"})
|
||||
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [m1, m2]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/metrics")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["metrics"]) == 2
|
||||
assert data["total"] == 2
|
||||
|
||||
def test_filter_by_category(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 1
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_metric_row({"category": "fairness"})]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/metrics?category=fairness")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["metrics"][0]["category"] == "fairness"
|
||||
|
||||
def test_filter_by_ai_system(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 1
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_metric_row({"ai_system": "ModelAlpha"})]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/metrics?ai_system=ModelAlpha")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["metrics"][0]["ai_system"] == "ModelAlpha"
|
||||
|
||||
def test_pagination(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 20
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_metric_row()]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/metrics?limit=1&offset=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 20
|
||||
assert len(data["metrics"]) == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — POST /quality/metrics
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateMetric:
|
||||
def test_create_success(self, mock_db):
|
||||
created_row = make_metric_row({"name": "New Metric"})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post(
|
||||
"/quality/metrics",
|
||||
json={"name": "New Metric"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "New Metric"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_full_metric(self, mock_db):
|
||||
created_row = make_metric_row({
|
||||
"name": "Robustness Score",
|
||||
"category": "robustness",
|
||||
"score": 78.5,
|
||||
"threshold": 75.0,
|
||||
"trend": "improving",
|
||||
})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post("/quality/metrics", json={
|
||||
"name": "Robustness Score",
|
||||
"category": "robustness",
|
||||
"score": 78.5,
|
||||
"threshold": 75.0,
|
||||
"trend": "improving",
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["category"] == "robustness"
|
||||
assert data["score"] == 78.5
|
||||
|
||||
def test_create_missing_name_fails(self, mock_db):
|
||||
response = client.post("/quality/metrics", json={"score": 90.0})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_with_tenant_header(self, mock_db):
|
||||
created_row = make_metric_row({"tenant_id": OTHER_TENANT})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post(
|
||||
"/quality/metrics",
|
||||
json={"name": "Tenant B metric"},
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert mock_db.commit.called
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — PUT /quality/metrics/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateMetric:
|
||||
def test_update_success(self, mock_db):
|
||||
updated_row = make_metric_row({"score": 95.0, "trend": "improving"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
|
||||
response = client.put(
|
||||
f"/quality/metrics/{METRIC_ID}",
|
||||
json={"score": 95.0, "trend": "improving"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["score"] == 95.0
|
||||
assert data["trend"] == "improving"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_update_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = None
|
||||
|
||||
response = client.put(
|
||||
"/quality/metrics/nonexistent-id",
|
||||
json={"score": 50.0},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_no_fields_returns_400(self, mock_db):
|
||||
response = client.put(f"/quality/metrics/{METRIC_ID}", json={})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_partial_update_category(self, mock_db):
|
||||
updated_row = make_metric_row({"category": "explainability"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
|
||||
response = client.put(
|
||||
f"/quality/metrics/{METRIC_ID}",
|
||||
json={"category": "explainability"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["category"] == "explainability"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — DELETE /quality/metrics/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteMetric:
|
||||
def test_delete_success_204(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 1
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete(f"/quality/metrics/{METRIC_ID}")
|
||||
assert response.status_code == 204
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_not_found_returns_404(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 0
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete("/quality/metrics/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_delete_with_tenant_header(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 1
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete(
|
||||
f"/quality/metrics/{METRIC_ID}",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — GET /quality/tests
|
||||
# =============================================================================
|
||||
|
||||
class TestListQualityTests:
|
||||
def test_empty_list(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = []
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/tests")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tests"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_with_data(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 2
|
||||
t1 = make_test_row()
|
||||
t2 = make_test_row({"id": "cccccccc-0002-0002-0002-000000000002", "name": "Second Test"})
|
||||
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [t1, t2]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/tests")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["tests"]) == 2
|
||||
assert data["total"] == 2
|
||||
|
||||
def test_filter_by_status(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 1
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_test_row({"status": "failed"})]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/tests?status=failed")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tests"][0]["status"] == "failed"
|
||||
|
||||
def test_filter_by_ai_system(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 1
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_test_row({"ai_system": "ModelBeta"})]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/tests?ai_system=ModelBeta")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tests"][0]["ai_system"] == "ModelBeta"
|
||||
|
||||
def test_pagination(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 50
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_test_row()]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/quality/tests?limit=1&offset=5")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total"] == 50
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — POST /quality/tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateQualityTest:
|
||||
def test_create_success(self, mock_db):
|
||||
created_row = make_test_row({"name": "New Test Run"})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post(
|
||||
"/quality/tests",
|
||||
json={"name": "New Test Run"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "New Test Run"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_full_test(self, mock_db):
|
||||
created_row = make_test_row({
|
||||
"name": "Full Test Suite",
|
||||
"status": "passed",
|
||||
"duration": "5.0s",
|
||||
"ai_system": "Classifier",
|
||||
"details": "All assertions passed",
|
||||
})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post("/quality/tests", json={
|
||||
"name": "Full Test Suite",
|
||||
"status": "passed",
|
||||
"duration": "5.0s",
|
||||
"ai_system": "Classifier",
|
||||
"details": "All assertions passed",
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["status"] == "passed"
|
||||
assert data["duration"] == "5.0s"
|
||||
|
||||
def test_create_missing_name_fails(self, mock_db):
|
||||
response = client.post("/quality/tests", json={"status": "passed"})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_failed_status(self, mock_db):
|
||||
created_row = make_test_row({"status": "failed"})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post(
|
||||
"/quality/tests",
|
||||
json={"name": "Failing test", "status": "failed"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["status"] == "failed"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — PUT /quality/tests/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateQualityTest:
|
||||
def test_update_success(self, mock_db):
|
||||
updated_row = make_test_row({"status": "failed", "details": "Assertion error on line 42"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
|
||||
response = client.put(
|
||||
f"/quality/tests/{TEST_ID}",
|
||||
json={"status": "failed", "details": "Assertion error on line 42"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["details"] == "Assertion error on line 42"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_update_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = None
|
||||
|
||||
response = client.put(
|
||||
"/quality/tests/nonexistent-id",
|
||||
json={"status": "passed"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_no_fields_returns_400(self, mock_db):
|
||||
response = client.put(f"/quality/tests/{TEST_ID}", json={})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_update_duration_only(self, mock_db):
|
||||
updated_row = make_test_row({"duration": "2.8s"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
|
||||
response = client.put(
|
||||
f"/quality/tests/{TEST_ID}",
|
||||
json={"duration": "2.8s"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["duration"] == "2.8s"
|
||||
|
||||
def test_update_with_tenant_header(self, mock_db):
|
||||
updated_row = make_test_row({"tenant_id": OTHER_TENANT, "status": "warning"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
|
||||
response = client.put(
|
||||
f"/quality/tests/{TEST_ID}",
|
||||
json={"status": "warning"},
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "warning"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — DELETE /quality/tests/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteQualityTest:
|
||||
def test_delete_success_204(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 1
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete(f"/quality/tests/{TEST_ID}")
|
||||
assert response.status_code == 204
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_not_found_returns_404(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 0
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete("/quality/tests/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_delete_with_tenant_header(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 1
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete(
|
||||
f"/quality/tests/{TEST_ID}",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert mock_db.commit.called
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tenant Isolation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestTenantIsolation:
|
||||
def test_metrics_tenant_isolation(self, mock_db):
|
||||
"""Tenant A sees 3 metrics, Tenant B sees 0."""
|
||||
def side_effect(query, params):
|
||||
result = MagicMock()
|
||||
tid = params.get("tenant_id", "")
|
||||
if tid == DEFAULT_TENANT:
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 3
|
||||
result.fetchone.return_value = count_row
|
||||
result.fetchall.return_value = [
|
||||
make_metric_row(),
|
||||
make_metric_row({"id": "bbbbbbbb-0002-0002-0002-000000000002"}),
|
||||
make_metric_row({"id": "bbbbbbbb-0003-0003-0003-000000000003"}),
|
||||
]
|
||||
else:
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
result.fetchone.return_value = count_row
|
||||
result.fetchall.return_value = []
|
||||
return result
|
||||
|
||||
mock_db.execute.side_effect = side_effect
|
||||
|
||||
resp_a = client.get(
|
||||
"/quality/metrics",
|
||||
headers={"X-Tenant-Id": DEFAULT_TENANT},
|
||||
)
|
||||
assert resp_a.json()["total"] == 3
|
||||
|
||||
resp_b = client.get(
|
||||
"/quality/metrics",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert resp_b.json()["total"] == 0
|
||||
|
||||
def test_tests_tenant_isolation(self, mock_db):
|
||||
"""Tenant A sees 2 tests, Tenant B sees 0."""
|
||||
def side_effect(query, params):
|
||||
result = MagicMock()
|
||||
tid = params.get("tenant_id", "")
|
||||
if tid == DEFAULT_TENANT:
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 2
|
||||
result.fetchone.return_value = count_row
|
||||
result.fetchall.return_value = [make_test_row(), make_test_row({"id": "cccccccc-0002-0002-0002-000000000002"})]
|
||||
else:
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
result.fetchone.return_value = count_row
|
||||
result.fetchall.return_value = []
|
||||
return result
|
||||
|
||||
mock_db.execute.side_effect = side_effect
|
||||
|
||||
resp_a = client.get("/quality/tests", headers={"X-Tenant-Id": DEFAULT_TENANT})
|
||||
assert resp_a.json()["total"] == 2
|
||||
|
||||
resp_b = client.get("/quality/tests", headers={"X-Tenant-Id": OTHER_TENANT})
|
||||
assert resp_b.json()["total"] == 0
|
||||
|
||||
def test_invalid_tenant_header_falls_back_to_default(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = []
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get(
|
||||
"/quality/metrics",
|
||||
headers={"X-Tenant-Id": "bad-uuid"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_delete_wrong_tenant_returns_404(self, mock_db):
|
||||
"""Deleting a metric that belongs to a different tenant returns 404."""
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 0
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete(
|
||||
f"/quality/metrics/{METRIC_ID}",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
698
backend-compliance/tests/test_security_backlog_routes.py
Normal file
698
backend-compliance/tests/test_security_backlog_routes.py
Normal file
@@ -0,0 +1,698 @@
|
||||
"""Tests for Security Backlog routes (security_backlog_routes.py).
|
||||
|
||||
Covers:
|
||||
- Schema validation (SecurityItemCreate, SecurityItemUpdate)
|
||||
- Helper functions (_row_to_dict, _get_tenant_id)
|
||||
- HTTP endpoints via FastAPI TestClient with mocked DB session
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.api.security_backlog_routes import (
|
||||
router,
|
||||
SecurityItemCreate,
|
||||
SecurityItemUpdate,
|
||||
_row_to_dict,
|
||||
_get_tenant_id,
|
||||
DEFAULT_TENANT_ID,
|
||||
)
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
# =============================================================================
|
||||
# TestClient Setup
|
||||
# =============================================================================
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
OTHER_TENANT = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
ITEM_ID = "aaaaaaaa-0001-0001-0001-000000000001"
|
||||
|
||||
|
||||
def make_item_row(overrides=None):
|
||||
data = {
|
||||
"id": ITEM_ID,
|
||||
"tenant_id": DEFAULT_TENANT,
|
||||
"title": "Test Item",
|
||||
"description": "Test description",
|
||||
"type": "vulnerability",
|
||||
"severity": "medium",
|
||||
"status": "open",
|
||||
"source": None,
|
||||
"cve": None,
|
||||
"cvss": None,
|
||||
"affected_asset": None,
|
||||
"assigned_to": None,
|
||||
"due_date": None,
|
||||
"remediation": None,
|
||||
"created_at": datetime(2024, 1, 1),
|
||||
"updated_at": datetime(2024, 1, 1),
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
row = MagicMock()
|
||||
row._mapping = data
|
||||
return row
|
||||
|
||||
|
||||
def make_stats_row(overrides=None):
|
||||
data = {
|
||||
"open": 3,
|
||||
"in_progress": 1,
|
||||
"resolved": 2,
|
||||
"accepted_risk": 0,
|
||||
"critical": 1,
|
||||
"high": 2,
|
||||
"overdue": 1,
|
||||
"total": 6,
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
row = MagicMock()
|
||||
row._mapping = data
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = MagicMock()
|
||||
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
yield db
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# Module-level client for simple tests that set up their own mocks per test
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — SecurityItemCreate
|
||||
# =============================================================================
|
||||
|
||||
class TestSecurityItemCreate:
|
||||
def test_minimal_valid(self):
|
||||
req = SecurityItemCreate(title="SQL Injection in Login")
|
||||
assert req.title == "SQL Injection in Login"
|
||||
assert req.type == "vulnerability"
|
||||
assert req.severity == "medium"
|
||||
assert req.status == "open"
|
||||
assert req.description is None
|
||||
assert req.source is None
|
||||
assert req.cve is None
|
||||
assert req.cvss is None
|
||||
assert req.affected_asset is None
|
||||
assert req.assigned_to is None
|
||||
assert req.due_date is None
|
||||
assert req.remediation is None
|
||||
|
||||
def test_full_values(self):
|
||||
due = datetime(2026, 6, 30)
|
||||
req = SecurityItemCreate(
|
||||
title="CVE-2024-1234",
|
||||
description="Remote code execution in parser",
|
||||
type="cve",
|
||||
severity="critical",
|
||||
status="in-progress",
|
||||
source="NVD",
|
||||
cve="CVE-2024-1234",
|
||||
cvss=9.8,
|
||||
affected_asset="api-server",
|
||||
assigned_to="security-team",
|
||||
due_date=due,
|
||||
remediation="Upgrade to v2.1.0",
|
||||
)
|
||||
assert req.title == "CVE-2024-1234"
|
||||
assert req.type == "cve"
|
||||
assert req.severity == "critical"
|
||||
assert req.cvss == 9.8
|
||||
assert req.cve == "CVE-2024-1234"
|
||||
assert req.assigned_to == "security-team"
|
||||
|
||||
def test_serialization_excludes_none(self):
|
||||
req = SecurityItemCreate(title="Patch missing", severity="high")
|
||||
data = req.model_dump(exclude_none=True)
|
||||
assert data["title"] == "Patch missing"
|
||||
assert data["severity"] == "high"
|
||||
assert "description" not in data
|
||||
assert "cve" not in data
|
||||
|
||||
def test_serialization_includes_defaults(self):
|
||||
req = SecurityItemCreate(title="Test")
|
||||
data = req.model_dump()
|
||||
assert data["type"] == "vulnerability"
|
||||
assert data["severity"] == "medium"
|
||||
assert data["status"] == "open"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests — SecurityItemUpdate
|
||||
# =============================================================================
|
||||
|
||||
class TestSecurityItemUpdate:
|
||||
def test_empty_update(self):
|
||||
req = SecurityItemUpdate()
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data == {}
|
||||
|
||||
def test_partial_update_status(self):
|
||||
req = SecurityItemUpdate(status="resolved")
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data == {"status": "resolved"}
|
||||
|
||||
def test_partial_update_severity(self):
|
||||
req = SecurityItemUpdate(severity="critical", assigned_to="john@example.com")
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert data["severity"] == "critical"
|
||||
assert data["assigned_to"] == "john@example.com"
|
||||
assert "title" not in data
|
||||
|
||||
def test_full_update(self):
|
||||
req = SecurityItemUpdate(
|
||||
title="Updated Title",
|
||||
description="New desc",
|
||||
type="misconfiguration",
|
||||
severity="low",
|
||||
status="accepted-risk",
|
||||
remediation="Accept the risk",
|
||||
)
|
||||
data = req.model_dump(exclude_unset=True)
|
||||
assert len(data) == 6
|
||||
assert data["type"] == "misconfiguration"
|
||||
assert data["status"] == "accepted-risk"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Tests — _row_to_dict
|
||||
# =============================================================================
|
||||
|
||||
class TestRowToDict:
|
||||
def test_basic_conversion(self):
|
||||
row = make_item_row()
|
||||
result = _row_to_dict(row)
|
||||
assert result["id"] == ITEM_ID
|
||||
assert result["title"] == "Test Item"
|
||||
assert result["severity"] == "medium"
|
||||
|
||||
def test_datetime_serialized(self):
|
||||
ts = datetime(2024, 6, 15, 10, 30, 0)
|
||||
row = make_item_row({"created_at": ts, "updated_at": ts})
|
||||
result = _row_to_dict(row)
|
||||
assert result["created_at"] == ts.isoformat()
|
||||
assert result["updated_at"] == ts.isoformat()
|
||||
|
||||
def test_none_values_preserved(self):
|
||||
row = make_item_row()
|
||||
result = _row_to_dict(row)
|
||||
assert result["source"] is None
|
||||
assert result["cve"] is None
|
||||
assert result["cvss"] is None
|
||||
assert result["affected_asset"] is None
|
||||
assert result["due_date"] is None
|
||||
|
||||
def test_uuid_converted_to_string(self):
|
||||
import uuid
|
||||
uid = uuid.UUID(DEFAULT_TENANT)
|
||||
row = MagicMock()
|
||||
row._mapping = {"id": uid, "tenant_id": uid}
|
||||
result = _row_to_dict(row)
|
||||
assert result["id"] == str(uid)
|
||||
assert result["tenant_id"] == str(uid)
|
||||
|
||||
def test_string_and_numeric_unchanged(self):
|
||||
row = MagicMock()
|
||||
row._mapping = {"title": "Test", "cvss": 7.5, "active": True}
|
||||
result = _row_to_dict(row)
|
||||
assert result["title"] == "Test"
|
||||
assert result["cvss"] == 7.5
|
||||
assert result["active"] is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Tests — _get_tenant_id
|
||||
# =============================================================================
|
||||
|
||||
class TestGetTenantId:
|
||||
def test_valid_uuid_returned(self):
|
||||
result = _get_tenant_id(x_tenant_id=DEFAULT_TENANT)
|
||||
assert result == DEFAULT_TENANT
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = _get_tenant_id(x_tenant_id=None)
|
||||
assert result == DEFAULT_TENANT_ID
|
||||
|
||||
def test_invalid_uuid_returns_default(self):
|
||||
result = _get_tenant_id(x_tenant_id="not-a-uuid")
|
||||
assert result == DEFAULT_TENANT_ID
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
result = _get_tenant_id(x_tenant_id="")
|
||||
assert result == DEFAULT_TENANT_ID
|
||||
|
||||
def test_different_valid_tenant(self):
|
||||
result = _get_tenant_id(x_tenant_id=OTHER_TENANT)
|
||||
assert result == OTHER_TENANT
|
||||
|
||||
def test_partial_uuid_returns_default(self):
|
||||
result = _get_tenant_id(x_tenant_id="9282a473-5c95-4b3a")
|
||||
assert result == DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — GET /security-backlog
|
||||
# =============================================================================
|
||||
|
||||
class TestListSecurityItems:
|
||||
def test_empty_list(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
mock_db.execute.return_value.fetchone.return_value = count_row
|
||||
mock_db.execute.return_value.fetchall.return_value = []
|
||||
|
||||
response = client.get("/security-backlog")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_with_data(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 2
|
||||
item1 = make_item_row()
|
||||
item2 = make_item_row({"id": "aaaaaaaa-0002-0002-0002-000000000002", "title": "Second Item"})
|
||||
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [item1, item2]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/security-backlog")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 2
|
||||
assert data["total"] == 2
|
||||
assert data["items"][0]["title"] == "Test Item"
|
||||
|
||||
def test_filter_by_status(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 1
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_item_row({"status": "resolved"})]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/security-backlog?status=resolved")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"][0]["status"] == "resolved"
|
||||
|
||||
def test_filter_by_severity(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 1
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_item_row({"severity": "critical"})]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/security-backlog?severity=critical")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"][0]["severity"] == "critical"
|
||||
|
||||
def test_filter_by_type(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 1
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_item_row({"type": "misconfiguration"})]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/security-backlog?type=misconfiguration")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"][0]["type"] == "misconfiguration"
|
||||
|
||||
def test_filter_by_search(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 1
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_item_row({"title": "SQL Injection"})]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/security-backlog?search=SQL")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "SQL" in data["items"][0]["title"]
|
||||
|
||||
def test_pagination_limit_offset(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 10
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = [make_item_row()]
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/security-backlog?limit=1&offset=5")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 10
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_default_tenant_used_without_header(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = []
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get("/security-backlog")
|
||||
assert response.status_code == 200
|
||||
# Verify execute was called (tenant was resolved internally)
|
||||
assert mock_db.execute.called
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — GET /security-backlog/stats
|
||||
# =============================================================================
|
||||
|
||||
class TestGetSecurityStats:
|
||||
def test_stats_all_zeros_empty(self, mock_db):
|
||||
zero_row = MagicMock()
|
||||
zero_row._mapping = {
|
||||
"open": 0, "in_progress": 0, "resolved": 0, "accepted_risk": 0,
|
||||
"critical": 0, "high": 0, "overdue": 0, "total": 0,
|
||||
}
|
||||
mock_db.execute.return_value.fetchone.return_value = zero_row
|
||||
|
||||
response = client.get("/security-backlog/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["open"] == 0
|
||||
assert data["critical"] == 0
|
||||
assert data["overdue"] == 0
|
||||
|
||||
def test_stats_with_data(self, mock_db):
|
||||
stats_row = make_stats_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = stats_row
|
||||
|
||||
response = client.get("/security-backlog/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 6
|
||||
assert data["open"] == 3
|
||||
assert data["critical"] == 1
|
||||
assert data["high"] == 2
|
||||
assert data["overdue"] == 1
|
||||
assert data["in_progress"] == 1
|
||||
assert data["resolved"] == 2
|
||||
assert data["accepted_risk"] == 0
|
||||
|
||||
def test_stats_none_values_become_zero(self, mock_db):
|
||||
none_row = MagicMock()
|
||||
none_row._mapping = {
|
||||
"open": None, "in_progress": None, "resolved": None, "accepted_risk": None,
|
||||
"critical": None, "high": None, "overdue": None, "total": None,
|
||||
}
|
||||
mock_db.execute.return_value.fetchone.return_value = none_row
|
||||
|
||||
response = client.get("/security-backlog/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for key in ("open", "in_progress", "resolved", "accepted_risk", "critical", "high", "overdue", "total"):
|
||||
assert data[key] == 0
|
||||
|
||||
def test_stats_with_tenant_header(self, mock_db):
|
||||
stats_row = make_stats_row({"total": 1, "open": 1})
|
||||
mock_db.execute.return_value.fetchone.return_value = stats_row
|
||||
|
||||
response = client.get(
|
||||
"/security-backlog/stats",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total"] == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — POST /security-backlog
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateSecurityItem:
|
||||
def test_create_success(self, mock_db):
|
||||
created_row = make_item_row({"title": "New Vulnerability"})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post(
|
||||
"/security-backlog",
|
||||
json={"title": "New Vulnerability"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["title"] == "New Vulnerability"
|
||||
assert data["id"] == ITEM_ID
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_full_item(self, mock_db):
|
||||
created_row = make_item_row({
|
||||
"title": "CVE-2024-9999",
|
||||
"type": "cve",
|
||||
"severity": "critical",
|
||||
"status": "open",
|
||||
"cve": "CVE-2024-9999",
|
||||
"cvss": 9.8,
|
||||
})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post("/security-backlog", json={
|
||||
"title": "CVE-2024-9999",
|
||||
"type": "cve",
|
||||
"severity": "critical",
|
||||
"cve": "CVE-2024-9999",
|
||||
"cvss": 9.8,
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["severity"] == "critical"
|
||||
assert data["cvss"] == 9.8
|
||||
|
||||
def test_create_missing_title_fails(self, mock_db):
|
||||
response = client.post("/security-backlog", json={"severity": "high"})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_with_tenant_header(self, mock_db):
|
||||
created_row = make_item_row({"tenant_id": OTHER_TENANT})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post(
|
||||
"/security-backlog",
|
||||
json={"title": "Tenant-specific item"},
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert mock_db.commit.called
|
||||
|
||||
def test_create_default_type_and_severity(self, mock_db):
|
||||
created_row = make_item_row()
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post("/security-backlog", json={"title": "Basic item"})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["type"] == "vulnerability"
|
||||
assert data["severity"] == "medium"
|
||||
assert data["status"] == "open"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — PUT /security-backlog/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateSecurityItem:
|
||||
def test_update_success(self, mock_db):
|
||||
updated_row = make_item_row({"status": "resolved", "severity": "low"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
|
||||
response = client.put(
|
||||
f"/security-backlog/{ITEM_ID}",
|
||||
json={"status": "resolved", "severity": "low"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "resolved"
|
||||
assert data["severity"] == "low"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_update_partial_title_only(self, mock_db):
|
||||
updated_row = make_item_row({"title": "Updated Title"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
|
||||
response = client.put(
|
||||
f"/security-backlog/{ITEM_ID}",
|
||||
json={"title": "Updated Title"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Updated Title"
|
||||
|
||||
def test_update_not_found_returns_404(self, mock_db):
|
||||
mock_db.execute.return_value.fetchone.return_value = None
|
||||
|
||||
response = client.put(
|
||||
"/security-backlog/nonexistent-id",
|
||||
json={"status": "resolved"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_update_no_fields_returns_400(self, mock_db):
|
||||
response = client.put(f"/security-backlog/{ITEM_ID}", json={})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_update_with_tenant_header(self, mock_db):
|
||||
updated_row = make_item_row({"tenant_id": OTHER_TENANT, "status": "in-progress"})
|
||||
mock_db.execute.return_value.fetchone.return_value = updated_row
|
||||
|
||||
response = client.put(
|
||||
f"/security-backlog/{ITEM_ID}",
|
||||
json={"status": "in-progress"},
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "in-progress"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Tests — DELETE /security-backlog/{id}
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteSecurityItem:
|
||||
def test_delete_success_204(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 1
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete(f"/security-backlog/{ITEM_ID}")
|
||||
assert response.status_code == 204
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_not_found_returns_404(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 0
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete("/security-backlog/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_delete_with_tenant_header(self, mock_db):
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 1
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete(
|
||||
f"/security-backlog/{ITEM_ID}",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert mock_db.commit.called
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tenant Isolation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestTenantIsolation:
|
||||
def test_different_tenant_sees_different_items(self, mock_db):
|
||||
"""Tenant A has 3 items, Tenant B has 0 — each sees only their own data."""
|
||||
call_count = 0
|
||||
|
||||
def side_effect(query, params):
|
||||
nonlocal call_count
|
||||
result = MagicMock()
|
||||
tid = params.get("tenant_id", "")
|
||||
if tid == DEFAULT_TENANT:
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 3
|
||||
result.fetchone.return_value = count_row
|
||||
result.fetchall.return_value = [
|
||||
make_item_row(),
|
||||
make_item_row({"id": "aaaaaaaa-0002-0002-0002-000000000002"}),
|
||||
make_item_row({"id": "aaaaaaaa-0003-0003-0003-000000000003"}),
|
||||
]
|
||||
else:
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
result.fetchone.return_value = count_row
|
||||
result.fetchall.return_value = []
|
||||
return result
|
||||
|
||||
mock_db.execute.side_effect = side_effect
|
||||
|
||||
resp_a = client.get(
|
||||
"/security-backlog",
|
||||
headers={"X-Tenant-Id": DEFAULT_TENANT},
|
||||
)
|
||||
assert resp_a.status_code == 200
|
||||
assert resp_a.json()["total"] == 3
|
||||
|
||||
resp_b = client.get(
|
||||
"/security-backlog",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert resp_b.status_code == 200
|
||||
assert resp_b.json()["total"] == 0
|
||||
|
||||
def test_invalid_tenant_header_falls_back_to_default(self, mock_db):
|
||||
count_row = MagicMock()
|
||||
count_row.__getitem__ = lambda self, i: 0
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = count_row
|
||||
execute_result.fetchall.return_value = []
|
||||
mock_db.execute.return_value = execute_result
|
||||
|
||||
response = client.get(
|
||||
"/security-backlog",
|
||||
headers={"X-Tenant-Id": "not-a-real-uuid"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Should succeed (falls back to DEFAULT_TENANT_ID)
|
||||
assert "items" in response.json()
|
||||
|
||||
def test_create_uses_tenant_from_header(self, mock_db):
|
||||
created_row = make_item_row({"tenant_id": OTHER_TENANT})
|
||||
mock_db.execute.return_value.fetchone.return_value = created_row
|
||||
|
||||
response = client.post(
|
||||
"/security-backlog",
|
||||
json={"title": "Tenant B item"},
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert mock_db.commit.called
|
||||
|
||||
def test_delete_tenant_isolation_not_found_for_wrong_tenant(self, mock_db):
|
||||
"""Deleting an item that belongs to a different tenant returns 404."""
|
||||
delete_result = MagicMock()
|
||||
delete_result.rowcount = 0 # No rows deleted (item belongs to other tenant)
|
||||
mock_db.execute.return_value = delete_result
|
||||
|
||||
response = client.delete(
|
||||
f"/security-backlog/{ITEM_ID}",
|
||||
headers={"X-Tenant-Id": OTHER_TENANT},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user