diff --git a/admin-compliance/app/sdk/audit-timeline/_components/CIDHistoryModal.tsx b/admin-compliance/app/sdk/audit-timeline/_components/CIDHistoryModal.tsx new file mode 100644 index 00000000..95aaac20 --- /dev/null +++ b/admin-compliance/app/sdk/audit-timeline/_components/CIDHistoryModal.tsx @@ -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 + 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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null) + const [diff, setDiff] = useState(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 ( +
+
e.stopPropagation()} + > +
+
+

DSMS-Versionsverlauf

+ {shorten(cid)} +
+ +
+ +
+ {loading &&
Verlauf wird geladen…
} + {error &&
{error}
} + + {!loading && !error && history.length === 0 && ( +
+ Kein Versionsverlauf gefunden. Diese CID hat keine parent_cid-Kette. +
+ )} + + {!loading && !error && history.length > 0 && ( + <> +
+ {history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben). +
+
    + {history.map((entry, idx) => { + const next = history[idx + 1] + return ( +
  1. +
    +
    +
    +
    +
    + Version {entry.version || '?'} {idx === 0 && AKTUELL} +
    + {entry.cid} +
    + {next && ( + + )} +
    +
    + {entry.document_type && Typ: {entry.document_type}} + {entry.document_id && Dok-ID: {entry.document_id}} + {entry.created_at && {new Date(entry.created_at).toLocaleString('de-DE')}} +
    + {entry.checksum && ( +
    SHA-256: {entry.checksum.slice(0, 16)}…
    + )} +
    +
  2. + ) + })} +
+ + )} + + {diffPair && ( +
+
+

+ Diff: {shorten(diffPair.a)} → {shorten(diffPair.b)} +

+ +
+ {diffLoading &&
Diff wird geladen…
} + {!diffLoading && diff && ( + <> + {Object.keys(diff.metadata_diff || {}).length > 0 && ( +
+
Metadaten-Aenderungen
+ + + {Object.entries(diff.metadata_diff).map(([field, { old, new: nv }]) => ( + + + + + + ))} + +
{field}{JSON.stringify(old)}{JSON.stringify(nv)}
+
+ )} + {diff.kind === 'text' && diff.diff && ( + <> +
+ {diff.added_lines ?? 0} Zeilen hinzu, {diff.removed_lines ?? 0} entfernt +
+
+                        {diff.diff}
+                      
+ + )} + {diff.kind === 'binary' && ( +
+ {diff.note || 'Binaere Datei — kein Text-Diff verfuegbar.'} +
+ )} + + )} +
+ )} +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/audit-timeline/page.tsx b/admin-compliance/app/sdk/audit-timeline/page.tsx index 0c62d370..c27b1cf9 100644 --- a/admin-compliance/app/sdk/audit-timeline/page.tsx +++ b/admin-compliance/app/sdk/audit-timeline/page.tsx @@ -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 = { evidence: 'Nachweis', control: 'Control', document: 'Dokument', @@ -16,8 +18,24 @@ const ACTION_COLORS: Record = { 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(null) return (
@@ -58,16 +76,18 @@ export default function AuditTimelinePage() {
{entries.map((entry) => ( - + ))}
)} + + {historyCID && setHistoryCID(null)} />} ) } -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 }) {

{entry.change_summary}

)} {isCID && entry.new_value && ( -
+
@@ -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} DSMS/IPFS +
)}
diff --git a/dsms-gateway/routers/documents.py b/dsms-gateway/routers/documents.py index a30f3ed3..1882c721 100644 --- a/dsms-gateway/routers/documents.py +++ b/dsms-gateway/routers/documents.py @@ -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) diff --git a/dsms-gateway/test_diff.py b/dsms-gateway/test_diff.py new file mode 100644 index 00000000..2ae8f47d --- /dev/null +++ b/dsms-gateway/test_diff.py @@ -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"]