Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4087bb5f18 | |||
| 85e758b250 | |||
| 916dec87ee | |||
| 5fc16dd61d | |||
| 46278cda5b | |||
| 75174273f4 | |||
| 6baf44ac84 | |||
| 299375e486 | |||
| 2b1fe3713a | |||
| 872145d883 | |||
| 9bdaa28038 |
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface HistoryEntry {
|
||||
cid: string
|
||||
version: string | null
|
||||
document_type: string | null
|
||||
document_id: string | null
|
||||
parent_cid: string | null
|
||||
created_at: string | null
|
||||
checksum: string | null
|
||||
}
|
||||
|
||||
interface DiffResponse {
|
||||
kind: 'text' | 'binary'
|
||||
cid_a: string
|
||||
cid_b: string
|
||||
metadata_diff: Record<string, { old: unknown; new: unknown }>
|
||||
diff?: string
|
||||
added_lines?: number
|
||||
removed_lines?: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cid: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function shorten(cid: string): string {
|
||||
if (cid.length <= 14) return cid
|
||||
return cid.slice(0, 8) + '…' + cid.slice(-6)
|
||||
}
|
||||
|
||||
export default function CIDHistoryModal({ cid, onClose }: Props) {
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null)
|
||||
const [diff, setDiff] = useState<DiffResponse | null>(null)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetch(`/api/sdk/v1/dsms/documents/${encodeURIComponent(cid)}/history`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const json = await r.json()
|
||||
if (!cancel) setHistory(json.history || [])
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancel) setError(e?.message || 'Fehler beim Laden')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancel) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [cid])
|
||||
|
||||
async function loadDiff(a: string, b: string) {
|
||||
setDiffPair({ a, b })
|
||||
setDiff(null)
|
||||
setDiffLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sdk/v1/dsms/documents/${encodeURIComponent(a)}/diff/${encodeURIComponent(b)}`
|
||||
)
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as DiffResponse
|
||||
setDiff(json)
|
||||
} else {
|
||||
setDiff({ kind: 'binary', cid_a: a, cid_b: b, metadata_diff: {}, note: `HTTP ${res.status}` })
|
||||
}
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-gray-800 rounded-xl shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">DSMS-Versionsverlauf</h2>
|
||||
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400">{shorten(cid)}</code>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{loading && <div className="text-sm text-gray-500">Verlauf wird geladen…</div>}
|
||||
{error && <div className="text-sm text-red-600 dark:text-red-400">{error}</div>}
|
||||
|
||||
{!loading && !error && history.length === 0 && (
|
||||
<div className="text-sm text-gray-500 italic">
|
||||
Kein Versionsverlauf gefunden. Diese CID hat keine parent_cid-Kette.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && history.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
|
||||
</div>
|
||||
<ol className="relative border-l-2 border-emerald-500/40 pl-4 space-y-3">
|
||||
{history.map((entry, idx) => {
|
||||
const next = history[idx + 1]
|
||||
return (
|
||||
<li key={entry.cid} className="relative">
|
||||
<div className="absolute -left-[1.4rem] top-1.5 w-3 h-3 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-gray-800" />
|
||||
<div className="bg-gray-50 dark:bg-gray-900/40 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Version {entry.version || '?'} {idx === 0 && <span className="ml-2 text-[10px] text-emerald-600 font-semibold">AKTUELL</span>}
|
||||
</div>
|
||||
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400 break-all">{entry.cid}</code>
|
||||
</div>
|
||||
{next && (
|
||||
<button
|
||||
onClick={() => loadDiff(next.cid, entry.cid)}
|
||||
className="shrink-0 text-[11px] text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
|
||||
title="Aenderungen zur Vorversion anzeigen"
|
||||
>
|
||||
Diff zu V{next.version || '?'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{entry.document_type && <span>Typ: {entry.document_type}</span>}
|
||||
{entry.document_id && <span>Dok-ID: {entry.document_id}</span>}
|
||||
{entry.created_at && <span>{new Date(entry.created_at).toLocaleString('de-DE')}</span>}
|
||||
</div>
|
||||
{entry.checksum && (
|
||||
<div className="mt-1 text-[10px] text-gray-400 font-mono">SHA-256: {entry.checksum.slice(0, 16)}…</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
|
||||
{diffPair && (
|
||||
<div className="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||
Diff: {shorten(diffPair.a)} → {shorten(diffPair.b)}
|
||||
</h3>
|
||||
<button onClick={() => { setDiff(null); setDiffPair(null) }} className="text-[11px] text-gray-500 hover:text-gray-700">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
{diffLoading && <div className="text-xs text-gray-500">Diff wird geladen…</div>}
|
||||
{!diffLoading && diff && (
|
||||
<>
|
||||
{Object.keys(diff.metadata_diff || {}).length > 0 && (
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">Metadaten-Aenderungen</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{Object.entries(diff.metadata_diff).map(([field, { old, new: nv }]) => (
|
||||
<tr key={field} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-0.5 pr-2 font-mono text-[10px] text-gray-500">{field}</td>
|
||||
<td className="py-0.5 pr-2 text-red-600 dark:text-red-400 line-through">{JSON.stringify(old)}</td>
|
||||
<td className="py-0.5 text-green-700 dark:text-green-400">{JSON.stringify(nv)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{diff.kind === 'text' && diff.diff && (
|
||||
<>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
{diff.added_lines ?? 0} Zeilen hinzu, {diff.removed_lines ?? 0} entfernt
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono whitespace-pre-wrap bg-gray-900 text-gray-100 p-3 rounded max-h-64 overflow-y-auto">
|
||||
{diff.diff}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
{diff.kind === 'binary' && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-400 italic">
|
||||
{diff.note || 'Binaere Datei — kein Text-Diff verfuegbar.'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||
import CIDHistoryModal from './_components/CIDHistoryModal'
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||
@@ -16,8 +18,24 @@ const ACTION_COLORS: Record<string, string> = {
|
||||
|
||||
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||
|
||||
// new_value may be a plain CID (from Python evidence flow) or a JSON envelope
|
||||
// {"cid":"X","filename":"...","size":"..."} (from the Go IACE tech-file flow).
|
||||
function extractCID(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (typeof parsed.cid === 'string') return parsed.cid
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export default function AuditTimelinePage() {
|
||||
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||
const [historyCID, setHistoryCID] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
@@ -58,16 +76,18 @@ export default function AuditTimelinePage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry) => (
|
||||
<TimelineEntry key={entry.id} entry={entry} />
|
||||
<TimelineEntry key={entry.id} entry={entry} onShowHistory={setHistoryCID} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyCID && <CIDHistoryModal cid={historyCID} onClose={() => setHistoryCID(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
function TimelineEntry({ entry, onShowHistory }: { entry: AuditEntry; onShowHistory: (cid: string) => void }) {
|
||||
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||
const date = new Date(entry.performed_at)
|
||||
@@ -94,7 +114,7 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||
)}
|
||||
{isCID && entry.new_value && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
@@ -102,6 +122,16 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||
</code>
|
||||
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (entry.new_value) onShowHistory(extractCID(entry.new_value))
|
||||
}}
|
||||
className="text-[10px] text-purple-600 hover:text-purple-800 dark:text-purple-400 underline-offset-2 hover:underline"
|
||||
title="DSMS-Versionsverlauf und Diff zur Vorversion anzeigen"
|
||||
>
|
||||
Verlauf anzeigen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateAP } from './useFMEA'
|
||||
|
||||
describe('calculateAP — AIAG-VDA 2019 Handbook Action Priority', () => {
|
||||
it('returns H for severity 10 with mid occurrence', () => {
|
||||
expect(calculateAP(10, 5, 5)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns H for severity 9 with low detection', () => {
|
||||
expect(calculateAP(9, 4, 7)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns M for severity 9 with low occurrence and good detection', () => {
|
||||
expect(calculateAP(9, 2, 5)).toBe('M')
|
||||
})
|
||||
|
||||
it('returns L for severity 9 with very low occurrence and detection', () => {
|
||||
expect(calculateAP(9, 1, 4)).toBe('L')
|
||||
})
|
||||
|
||||
it('returns H for severity 7 with high occurrence', () => {
|
||||
expect(calculateAP(7, 5, 1)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns M for severity 7 with mid occurrence', () => {
|
||||
expect(calculateAP(7, 3, 5)).toBe('M')
|
||||
})
|
||||
|
||||
it('returns L for low-severity well-controlled mode', () => {
|
||||
expect(calculateAP(3, 1, 1)).toBe('L')
|
||||
})
|
||||
|
||||
it('returns L for severity 5 with very low occurrence and detection', () => {
|
||||
expect(calculateAP(5, 1, 1)).toBe('L')
|
||||
})
|
||||
})
|
||||
@@ -156,5 +156,52 @@ export function useFMEA(projectId: string) {
|
||||
// Get unique components for the suggest button
|
||||
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||
|
||||
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
||||
/**
|
||||
* Accept a suggested FM: build an FMEA row from the FM defaults, prepend it
|
||||
* to the table state, and remove the FM from the suggestion list.
|
||||
* Returns false if the (component, fm.id) combo already exists in rows.
|
||||
*/
|
||||
function acceptSuggestion(fm: FailureMode, componentId: string): boolean {
|
||||
const comp = components.find((c) => c.id === componentId)
|
||||
if (!comp) return false
|
||||
const dup = rows.find((r) => r.component.id === componentId && r.failureMode.id === fm.id)
|
||||
if (dup) {
|
||||
// Still drop the suggestion so the UI does not keep offering it.
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== fm.id))
|
||||
return false
|
||||
}
|
||||
const s = fm.default_severity || 5
|
||||
const o = fm.default_occurrence || 5
|
||||
const d = fm.default_detection || 5
|
||||
const newRow: FMEARow = {
|
||||
component: comp,
|
||||
failureMode: fm,
|
||||
severity: s,
|
||||
occurrence: o,
|
||||
detection: d,
|
||||
rpz: s * o * d,
|
||||
ap: calculateAP(s, o, d),
|
||||
}
|
||||
setRows((prev) => [newRow, ...prev].sort((a, b) => b.rpz - a.rpz))
|
||||
setSuggestions((prev) => prev.filter((sg) => sg.id !== fm.id))
|
||||
return true
|
||||
}
|
||||
|
||||
function rejectSuggestion(fmId: string) {
|
||||
setSuggestions((prev) => prev.filter((sg) => sg.id !== fmId))
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
loading,
|
||||
stats,
|
||||
components,
|
||||
suggestFMs,
|
||||
suggesting,
|
||||
suggestions,
|
||||
suggestSource,
|
||||
setSuggestions,
|
||||
acceptSuggestion,
|
||||
rejectSuggestion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
||||
|
||||
@@ -27,8 +27,17 @@ function rpzLabel(rpz: number): string {
|
||||
|
||||
export default function FMEAPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
|
||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions, acceptSuggestion, rejectSuggestion } = useFMEA(projectId)
|
||||
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
||||
const [acceptedCount, setAcceptedCount] = useState(0)
|
||||
|
||||
// Reset accepted-count when a fresh suggestion run is loaded or the panel closes.
|
||||
useEffect(() => {
|
||||
if (suggesting) setAcceptedCount(0)
|
||||
}, [suggesting])
|
||||
useEffect(() => {
|
||||
if (suggestions.length === 0) setAcceptedCount(0)
|
||||
}, [suggestions.length])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -97,26 +106,60 @@ export default function FMEAPage() {
|
||||
{suggestions.length > 0 && (
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
|
||||
</h3>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek-Fallback'}
|
||||
</h3>
|
||||
{acceptedCount > 0 && (
|
||||
<div className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||
{acceptedCount} Vorschlag{acceptedCount > 1 ? 'e' : ''} uebernommen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((fm, i) => (
|
||||
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>S={fm.default_severity}</span>
|
||||
<span>O={fm.default_occurrence}</span>
|
||||
<span>D={fm.default_detection}</span>
|
||||
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
|
||||
{suggestions.map((fm) => {
|
||||
const rpz = fm.default_severity * fm.default_occurrence * fm.default_detection
|
||||
return (
|
||||
<div key={fm.id} className="flex items-start justify-between gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>S={fm.default_severity}</span>
|
||||
<span>O={fm.default_occurrence}</span>
|
||||
<span>D={fm.default_detection}</span>
|
||||
<span className={`font-bold ${rpz > 200 ? 'text-red-600' : rpz > 100 ? 'text-orange-600' : 'text-gray-500'}`}>RPZ={rpz}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!suggestComp) return
|
||||
const ok = acceptSuggestion(fm, suggestComp)
|
||||
if (ok) setAcceptedCount((c) => c + 1)
|
||||
}}
|
||||
disabled={!suggestComp}
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded transition-colors"
|
||||
title="Diesen Fehlermodus der FMEA-Tabelle hinzufuegen"
|
||||
>
|
||||
Uebernehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectSuggestion(fm.id)}
|
||||
className="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-xs font-medium rounded transition-colors"
|
||||
title="Diesen Vorschlag verwerfen"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[10px] text-purple-700 dark:text-purple-400 mt-3">
|
||||
Hinweis: Uebernommene Fehlermodi erscheinen sofort in der Tabelle unten. Bewertung (S/O/D) ist anpassbar — Standardwerte aus der Bibliothek.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/benchmark" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>} label="Branchen-Benchmark" isActive={pathname?.startsWith('/sdk/benchmark') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* CRA Compliance */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -412,7 +413,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("PDF export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.pdf", safeName), projectID.String())
|
||||
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.pdf", safeName), projectID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.pdf"`, safeName))
|
||||
c.Data(http.StatusOK, "application/pdf", data)
|
||||
|
||||
@@ -422,7 +423,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Excel export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.xlsx", safeName), projectID.String())
|
||||
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.xlsx", safeName), projectID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.xlsx"`, safeName))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||
|
||||
@@ -432,7 +433,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("DOCX export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.docx", safeName), projectID.String())
|
||||
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.docx", safeName), projectID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.docx"`, safeName))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
|
||||
|
||||
@@ -442,7 +443,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Markdown export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.md", safeName), projectID.String())
|
||||
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.md", safeName), projectID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.md"`, safeName))
|
||||
c.Data(http.StatusOK, "text/markdown", data)
|
||||
|
||||
@@ -468,7 +469,30 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// archiveTechFile stores a tech-file export to DSMS (best-effort, non-blocking).
|
||||
func archiveTechFile(data []byte, filename, projectID string) {
|
||||
dsms.Archive(data, filename, "ce_techfile", projectID, "1")
|
||||
// archiveTechFile stores a tech-file export to DSMS (best-effort, non-blocking)
|
||||
// AND records the resulting CID in the IACE audit trail so the export is
|
||||
// traceable. The "new_values" JSON carries the CID + filename so the audit
|
||||
// timeline can later resolve the CID against the DSMS gateway for verify.
|
||||
func (h *IACEHandler) archiveTechFile(c *gin.Context, data []byte, filename string, projectID uuid.UUID) {
|
||||
result := dsms.Archive(data, filename, "ce_techfile", projectID.String(), "1")
|
||||
if result == nil || result.CID == "" {
|
||||
return
|
||||
}
|
||||
payload := map[string]string{
|
||||
"cid": result.CID,
|
||||
"filename": filename,
|
||||
"size": fmt.Sprintf("%d", result.Size),
|
||||
}
|
||||
newValues, _ := json.Marshal(payload)
|
||||
userID := rbac.GetUserID(c)
|
||||
_ = h.store.AddAuditEntry(
|
||||
c.Request.Context(),
|
||||
projectID,
|
||||
"tech_file_export",
|
||||
projectID,
|
||||
iace.AuditActionCreate,
|
||||
userID.String(),
|
||||
nil,
|
||||
newValues,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package dsms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArchive_Success_ReturnsCID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/api/v1/documents" {
|
||||
http.Error(w, "wrong route", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
http.Error(w, "wrong content-type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
http.Error(w, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(ArchiveResult{
|
||||
CID: "bafytest123",
|
||||
Size: 42,
|
||||
GatewayURL: "/ipfs/bafytest123",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
old := gatewayURL
|
||||
defer func() { gatewayURL = old }()
|
||||
gatewayURL = server.URL
|
||||
|
||||
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result on 200 OK")
|
||||
}
|
||||
if got.CID != "bafytest123" {
|
||||
t.Errorf("expected CID bafytest123, got %q", got.CID)
|
||||
}
|
||||
if got.Size != 42 {
|
||||
t.Errorf("expected Size 42, got %d", got.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchive_GatewayDown_ReturnsNil(t *testing.T) {
|
||||
old := gatewayURL
|
||||
defer func() { gatewayURL = old }()
|
||||
gatewayURL = "http://127.0.0.1:1" // unreachable
|
||||
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil when gateway unreachable, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchive_GatewayReturnsError_ReturnsNil(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
old := gatewayURL
|
||||
defer func() { gatewayURL = old }()
|
||||
gatewayURL = server.URL
|
||||
|
||||
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil on 500 response, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,15 @@ _PROCESS_INTERNAL_PATTERNS = [
|
||||
r"\bsensibilisier",
|
||||
# Vertraege intern
|
||||
r"\bauftragsverarbeitungsvertrag\b",
|
||||
r"\bAVV\b\s+abgeschlossen",
|
||||
r"\bavv\b\s+abgeschlossen",
|
||||
r"\bvertrag.*abgeschlossen",
|
||||
r"\bdpa\s+(geschlossen|abgeschlossen|vorhanden)",
|
||||
r"\bSCC\s+(geschlossen|abgeschlossen|implementiert)",
|
||||
# Technisch-organisatorische Massnahmen (intern)
|
||||
r"\bscc\s+(geschlossen|abgeschlossen|implementiert)",
|
||||
# Technisch-organisatorische Massnahmen (intern). Lowercase: blob
|
||||
# ist bereits .lower(); Case-sensitive Patterns (TOM/AVV/SCC) matchen
|
||||
# nie. Daher hier explizit klein.
|
||||
r"\btechnisch[-\s]*organisatorische\s+ma(ß|ss)nahmen?\b",
|
||||
r"\bTOM\s+(umgesetzt|dokumentiert|implementiert)",
|
||||
r"\btom\s+(umgesetzt|dokumentiert|implementiert)",
|
||||
r"\bverschluesselung\s+(implementiert|aktiv)",
|
||||
r"\bpseudonymisierung\s+(implementiert|aktiv)",
|
||||
r"\bbackup[s]?\s+(eingerichtet|vorhanden)",
|
||||
@@ -68,7 +70,11 @@ _PROCESS_INTERNAL_PATTERNS = [
|
||||
r"\bbitte\s+(intern\s+)?dokumentieren",
|
||||
r"\bin\s+der\s+verfahrens",
|
||||
r"\bnach\s+innen\s+geh",
|
||||
r"\bausnahmen\s+(dokumentieren|protokollieren)",
|
||||
# "Ausnahmen ... dokumentieren" — Wortabstand bis 60 Zeichen erlauben,
|
||||
# damit "ausnahmen bei bereits vorhandenen informationen dokumentieren"
|
||||
# matcht. Sonst Pattern zu eng → process-internal-MC bleibt FAIL.
|
||||
r"\bausnahmen\b[^\n]{0,60}\b(dokumentieren|protokollieren)\b",
|
||||
r"\bdokumentations[\s-]?pflicht",
|
||||
r"\bkostenfrei\s+(zur\s+verfuegung|gewaehren|ermoegli)",
|
||||
r"\bunentgeltlich\s+(zur\s+verfuegung)",
|
||||
# Vertragsleistung / Service-Level (intern)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Heuristic-coverage tests for mc_audit_type.classify_mc_audit_type.
|
||||
|
||||
Hand-kuratierte Beispiele aus DSGVO-/AI-Act-/eCall-Kontext, die nahe an
|
||||
den real-world FAILs aus VW-/BMW-Audits liegen. Wenn ein Pattern hier
|
||||
fehlschlaegt, fehlt _PROCESS_INTERNAL_PATTERNS bzw. _VERIFIABLE_PATTERNS
|
||||
eine entsprechende Regel.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from compliance.services.mc_audit_type import classify_mc_audit_type
|
||||
|
||||
|
||||
VERIFIABLE_CASES = [
|
||||
("Datenschutzerklaerung nennt Kontaktdaten des Verantwortlichen",
|
||||
"Steht im Impressum / DSE der Name + Adresse + Email?"),
|
||||
("Cookie-Banner enthaelt Ablehnen-Button",
|
||||
"Ist im Cookie-Banner ein 'Ablehnen' Button sichtbar?"),
|
||||
("Datenschutzerklaerung verweist auf Recht auf Auskunft",
|
||||
"Wird in der Datenschutzerklaerung Art. 15 DSGVO erwaehnt?"),
|
||||
("Cookie-Richtlinie listet alle Drittanbieter",
|
||||
"Sind alle Vendors in der Cookie-Richtlinie aufgefuehrt?"),
|
||||
("Impressum nennt Telefonnummer",
|
||||
"Steht im Impressum eine Kontakttelefonnummer?"),
|
||||
("Datenschutzerklaerung nennt Speicherdauer",
|
||||
"Sind in der DSE die Aufbewahrungsfristen angegeben?"),
|
||||
("Cookie-Banner Save-Label entspricht UWG-Vorgabe",
|
||||
"Ist der Speichern-Button im Banner korrekt beschriftet?"),
|
||||
]
|
||||
|
||||
PROCESS_INTERNAL_CASES = [
|
||||
("Mitarbeiter-Schulung zu Datenschutz durchgefuehrt",
|
||||
"Wurden alle Mitarbeiter zum Thema Datenschutz geschult?"),
|
||||
("AVV mit allen Auftragsverarbeitern abgeschlossen",
|
||||
"Liegt fuer jeden Dienst ein Auftragsverarbeitungsvertrag vor?"),
|
||||
("TOM dokumentiert und implementiert",
|
||||
"Sind die technisch-organisatorischen Massnahmen umgesetzt?"),
|
||||
("DSFA fuer Hochrisiko-Verarbeitung durchgefuehrt",
|
||||
"Wurde eine DSFA durchgefuehrt und dokumentiert?"),
|
||||
("Rollenkonzept eingerichtet",
|
||||
"Existiert ein Berechtigungskonzept mit klaren Rollen?"),
|
||||
("Pseudonymisierung von Daten implementiert",
|
||||
"Wird Pseudonymisierung aktiv eingesetzt?"),
|
||||
("Backup-Strategie eingerichtet",
|
||||
"Sind Backups vorhanden und werden regelmaessig getestet?"),
|
||||
("Abschaltung der Standortdatenverarbeitung kostenfrei ermoeglichen",
|
||||
"Kann der Nutzer die Standortdatenverarbeitung kostenfrei abschalten?"),
|
||||
("Ausnahmen bei bereits vorhandenen Informationen dokumentieren",
|
||||
"Werden Ausnahmen intern dokumentiert?"),
|
||||
("Sensibilisierung neue Mitarbeiter",
|
||||
"Erhalten neue Mitarbeiter eine Awareness-Schulung?"),
|
||||
("72-Stunden-Meldung an Aufsichtsbehoerde eingehalten",
|
||||
"Wird die Meldepflicht innerhalb von 72 Stunden umgesetzt?"),
|
||||
]
|
||||
|
||||
DOC_INTERNAL_CASES = [
|
||||
("Verzeichnis von Verarbeitungstaetigkeiten gefuehrt",
|
||||
"Existiert ein VVT mit allen Pflichtangaben?"),
|
||||
("Subprozessor-Liste aktualisiert",
|
||||
"Wird die Sub-Prozessor-Liste gepflegt?"),
|
||||
("Auftragsverarbeitungsverzeichnis vollstaendig",
|
||||
"Sind alle AVVs im Auftragsverarbeitungsverzeichnis erfasst?"),
|
||||
("Aufbewahrungskonzept dokumentiert",
|
||||
"Existiert ein dokumentiertes Aufbewahrungskonzept?"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title,question", VERIFIABLE_CASES)
|
||||
def test_verifiable_cases(title: str, question: str) -> None:
|
||||
"""These MCs MUST be classified as verifiable — they describe things
|
||||
visible from the outside (website / banner / DSE)."""
|
||||
result = classify_mc_audit_type(title, question)
|
||||
assert result == "verifiable", (
|
||||
f"Expected 'verifiable' for {title!r}, got {result!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title,question", PROCESS_INTERNAL_CASES)
|
||||
def test_process_internal_cases(title: str, question: str) -> None:
|
||||
"""These MCs MUST be classified as process_internal — they describe
|
||||
internal customer processes that we cannot verify from the outside."""
|
||||
result = classify_mc_audit_type(title, question)
|
||||
assert result == "process_internal", (
|
||||
f"Expected 'process_internal' for {title!r}, got {result!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title,question", DOC_INTERNAL_CASES)
|
||||
def test_doc_internal_cases(title: str, question: str) -> None:
|
||||
"""These MCs MUST be classified as doc_internal — they describe
|
||||
internal documentation (VVT, AVV-Verzeichnis) we cannot inspect."""
|
||||
result = classify_mc_audit_type(title, question)
|
||||
assert result == "doc_internal", (
|
||||
f"Expected 'doc_internal' for {title!r}, got {result!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_empty_input_is_ambiguous() -> None:
|
||||
assert classify_mc_audit_type("", "") == "ambiguous"
|
||||
assert classify_mc_audit_type(None, None) == "ambiguous"
|
||||
@@ -80,21 +80,33 @@ class CMPCapture:
|
||||
data = await _parse_json_response(response)
|
||||
if data is None:
|
||||
return
|
||||
# Skip tiny payloads — real CMP cookie policies are ≥5KB.
|
||||
# A 4KB JSON of cookie-shaped data is almost never the policy.
|
||||
try:
|
||||
size_kb = len(json.dumps(data)) // 1024
|
||||
except Exception:
|
||||
size_kb = 0
|
||||
if size_kb < 5:
|
||||
return
|
||||
from services.cmp_heuristic import looks_like_cookie_policy
|
||||
if looks_like_cookie_policy(data):
|
||||
matched = looks_like_cookie_policy(data)
|
||||
if matched and size_kb >= 5:
|
||||
self.payloads.append(("_heuristic", data))
|
||||
logger.info(
|
||||
"CMP captured: _heuristic (%s, ~%dKB)",
|
||||
url[:120], size_kb,
|
||||
)
|
||||
elif size_kb >= 3:
|
||||
# Phase-0-Diagnose-Log: JSON-Response die als CMP-Kandidat
|
||||
# ueberlebt hat, aber heuristic OR size-threshold abgelehnt
|
||||
# wurde. Zeigt beim naechsten VW/BMW/... Run welche Endpoints
|
||||
# uebersehen werden — schneller Pattern-Add ohne raten.
|
||||
top_keys = []
|
||||
if isinstance(data, dict):
|
||||
top_keys = list(data.keys())[:8]
|
||||
elif isinstance(data, list) and data and isinstance(data[0], dict):
|
||||
top_keys = list(data[0].keys())[:8]
|
||||
logger.info(
|
||||
"CMP candidate skipped: url=%s size=%dKB heuristic=%s "
|
||||
"top_keys=%s",
|
||||
url[:120], size_kb, matched, top_keys,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("CMP listener error: %s", e)
|
||||
|
||||
|
||||
@@ -256,7 +256,8 @@ async def archive_legal_document(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/documents/{cid}/history")
|
||||
@router.get("/api/v1/documents/{cid}/history")
|
||||
@router.get("/documents/{cid}/history") # legacy path, kept for backwards compatibility
|
||||
async def get_document_history(cid: str):
|
||||
"""Follow the parent_cid chain to reconstruct version history."""
|
||||
history = []
|
||||
@@ -285,3 +286,99 @@ async def get_document_history(cid: str):
|
||||
break
|
||||
|
||||
return {"cid": cid, "history": history, "depth": len(history)}
|
||||
|
||||
|
||||
@router.get("/api/v1/documents/{cid_a}/diff/{cid_b}")
|
||||
async def diff_documents(cid_a: str, cid_b: str):
|
||||
"""
|
||||
Compare two DSMS document versions by their CIDs.
|
||||
|
||||
Returns a unified diff of the textual content when both documents are
|
||||
text-decodable (UTF-8). For binary documents the response indicates
|
||||
"binary" and returns just the metadata differences. Used by the Audit
|
||||
Timeline UI to render "what changed between V2 and V3 of CE-Akte X".
|
||||
"""
|
||||
try:
|
||||
raw_a = await ipfs_cat(cid_a)
|
||||
raw_b = await ipfs_cat(cid_b)
|
||||
except Exception as exc:
|
||||
return {"error": f"could not fetch one of the CIDs: {exc}", "cid_a": cid_a, "cid_b": cid_b}
|
||||
|
||||
try:
|
||||
pkg_a = json.loads(raw_a)
|
||||
pkg_b = json.loads(raw_b)
|
||||
except Exception:
|
||||
# Documents are not the wrapped-package JSON shape — treat as raw.
|
||||
pkg_a = {"metadata": {}, "content_base64": ""}
|
||||
pkg_b = {"metadata": {}, "content_base64": ""}
|
||||
|
||||
meta_a = pkg_a.get("metadata", {}) or {}
|
||||
meta_b = pkg_b.get("metadata", {}) or {}
|
||||
meta_diff = _diff_metadata(meta_a, meta_b)
|
||||
|
||||
# Try to decode the content. The Archive flow stores files as base64 in
|
||||
# `content_base64`; older payloads may use `content` (utf-8 text).
|
||||
text_a, text_b, is_binary = _extract_texts(pkg_a, pkg_b)
|
||||
|
||||
if is_binary:
|
||||
return {
|
||||
"cid_a": cid_a,
|
||||
"cid_b": cid_b,
|
||||
"kind": "binary",
|
||||
"metadata_diff": meta_diff,
|
||||
"note": "Binary payload — text diff omitted. Compare via the rendered tech-file export instead.",
|
||||
}
|
||||
|
||||
diff_lines = list(
|
||||
_unified_diff(text_a.splitlines(), text_b.splitlines(), fromfile=cid_a, tofile=cid_b, lineterm="")
|
||||
)
|
||||
return {
|
||||
"cid_a": cid_a,
|
||||
"cid_b": cid_b,
|
||||
"kind": "text",
|
||||
"metadata_diff": meta_diff,
|
||||
"diff": "\n".join(diff_lines),
|
||||
"added_lines": sum(1 for ln in diff_lines if ln.startswith("+") and not ln.startswith("+++")),
|
||||
"removed_lines": sum(1 for ln in diff_lines if ln.startswith("-") and not ln.startswith("---")),
|
||||
}
|
||||
|
||||
|
||||
def _diff_metadata(a: dict, b: dict) -> dict:
|
||||
"""Return per-field change list: {field: {"old": ..., "new": ...}}."""
|
||||
keys = set(a.keys()) | set(b.keys())
|
||||
changes = {}
|
||||
for k in sorted(keys):
|
||||
if a.get(k) != b.get(k):
|
||||
changes[k] = {"old": a.get(k), "new": b.get(k)}
|
||||
return changes
|
||||
|
||||
|
||||
def _extract_texts(pkg_a: dict, pkg_b: dict) -> tuple[str, str, bool]:
|
||||
"""Return (text_a, text_b, is_binary). Falls back to base64-decode."""
|
||||
import base64
|
||||
|
||||
def to_text(pkg: dict) -> tuple[str, bool]:
|
||||
if isinstance(pkg.get("content"), str):
|
||||
return pkg["content"], False
|
||||
b64 = pkg.get("content_base64")
|
||||
if not b64:
|
||||
return "", False
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return "", True
|
||||
try:
|
||||
return raw.decode("utf-8"), False
|
||||
except UnicodeDecodeError:
|
||||
return "", True
|
||||
|
||||
text_a, bin_a = to_text(pkg_a)
|
||||
text_b, bin_b = to_text(pkg_b)
|
||||
return text_a, text_b, (bin_a or bin_b)
|
||||
|
||||
|
||||
def _unified_diff(a, b, fromfile, tofile, lineterm):
|
||||
"""Tiny shim around difflib.unified_diff so the function reads cleanly."""
|
||||
import difflib
|
||||
|
||||
return difflib.unified_diff(a, b, fromfile=fromfile, tofile=tofile, lineterm=lineterm, n=2)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Tests for the version-chain diff endpoint added in DSMS Stufe 3.
|
||||
|
||||
Mocks ipfs_cat so the test does not require a running IPFS node.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _wrap(metadata: dict, content_text: str) -> str:
|
||||
"""Mimic the JSON envelope that routers.documents.ipfs_cat returns."""
|
||||
return json.dumps(
|
||||
{
|
||||
"metadata": metadata,
|
||||
"content_base64": base64.b64encode(content_text.encode("utf-8")).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_text_documents_returns_unified_diff():
|
||||
pkg_a = _wrap({"version": "1", "document_type": "ce_techfile"}, "alpha\nbeta\ngamma\n")
|
||||
pkg_b = _wrap({"version": "2", "document_type": "ce_techfile"}, "alpha\nDELTA\ngamma\n")
|
||||
|
||||
async def fake_cat(cid: str):
|
||||
return pkg_a if cid == "cidA" else pkg_b
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["kind"] == "text"
|
||||
assert body["cid_a"] == "cidA"
|
||||
assert body["cid_b"] == "cidB"
|
||||
assert body["added_lines"] >= 1
|
||||
assert body["removed_lines"] >= 1
|
||||
assert "DELTA" in body["diff"]
|
||||
assert body["metadata_diff"] == {"version": {"old": "1", "new": "2"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_binary_documents_returns_metadata_only():
|
||||
# Use raw bytes that are not utf-8 decodable
|
||||
invalid_utf8 = b"\xff\xfe\xfd\xfc"
|
||||
pkg_a = json.dumps(
|
||||
{"metadata": {"version": "1"}, "content_base64": base64.b64encode(invalid_utf8).decode()}
|
||||
)
|
||||
pkg_b = json.dumps(
|
||||
{"metadata": {"version": "2"}, "content_base64": base64.b64encode(invalid_utf8 + b"\x00").decode()}
|
||||
)
|
||||
|
||||
async def fake_cat(cid: str):
|
||||
return pkg_a if cid == "cidA" else pkg_b
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["kind"] == "binary"
|
||||
assert body["metadata_diff"] == {"version": {"old": "1", "new": "2"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_handles_fetch_error():
|
||||
async def fake_cat(cid: str):
|
||||
raise RuntimeError("not pinned")
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "error" in body
|
||||
assert body["cid_a"] == "cidA"
|
||||
assert body["cid_b"] == "cidB"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_endpoint_follows_parent_chain():
|
||||
"""Sanity check that the existing history endpoint still works after route alias."""
|
||||
|
||||
chain = {
|
||||
"v3": _wrap({"version": "3", "parent_cid": "v2"}, "x"),
|
||||
"v2": _wrap({"version": "2", "parent_cid": "v1"}, "x"),
|
||||
"v1": _wrap({"version": "1", "parent_cid": None}, "x"),
|
||||
}
|
||||
|
||||
async def fake_cat(cid: str):
|
||||
return chain[cid]
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/v3/history")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["depth"] == 3
|
||||
assert [h["version"] for h in body["history"]] == ["3", "2", "1"]
|
||||
Reference in New Issue
Block a user