feat(iace): DSMS-CID-Badge im Tech-File-Export + aggregierter Bulk-Diff
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 10s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m21s
CI / test-go (push) Failing after 37s
CI / iace-gt-coverage (push) Successful in 23s
CI / test-python-backend (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Successful in 17s
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 10s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m21s
CI / test-go (push) Failing after 37s
CI / iace-gt-coverage (push) Successful in 23s
CI / test-python-backend (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Successful in 17s
Punkt 1 — UI-CID-Badge nach erfolgreichem Tech-File-Export:
- archiveTechFile setzt X-DSMS-CID / X-DSMS-Filename / X-DSMS-Size response
headers + Access-Control-Expose-Headers, sobald DSMS-Archive durchlief
- Split iace_handler_techfile.go (war ueber 500 LOC) → archiveTechFile lebt
jetzt in iace_handler_techfile_archive.go, setDSMSResponseHeaders als
pure Helper mit 3 unit tests
- Next.js IACE-Proxy forwarded die X-DSMS-* Header und erkennt jetzt auch
XLSX/DOCX/MD als Binary-Response (vorher nur PDF/ZIP/octet-stream)
- ExportCIDBadge.tsx zeigt CID, Filename, Groesse + Kopieren-Button +
"Verlauf anzeigen" (oeffnet CIDHistoryModal)
Punkt 2 — Bulk-Diff Report V1 → V_latest:
- Neuer Endpoint GET /api/v1/documents/{cid}/bulk-diff im dsms-gateway:
laeuft parent_cid-Kette ab, berechnet chronologische Step-Diffs,
aggregiert Totals (added/removed lines, metadata_fields_changed,
binary_steps). Edge-Cases: einzelne Version, binaere Steps, abgebrochene
Kette
- BulkDiffPanel.tsx zeigt 4-Stat-Header + Step-Tabelle
- CIDHistoryModal bekommt Toggle-Button "Bulk-Diff V1 → V_latest anzeigen"
neben dem Versions-Counter; damit auch vom IACE-Export-Badge erreichbar
Tests: 3 neue Go-Tests, 4 neue pytest-Tests, alle gruen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,18 +66,31 @@ async function proxyRequest(
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Handle non-JSON responses (PDF exports, ZIP CE technical file)
|
||||
const responseContentType = response.headers.get('content-type')
|
||||
if (responseContentType?.includes('application/pdf') ||
|
||||
responseContentType?.includes('application/zip') ||
|
||||
responseContentType?.includes('application/octet-stream')) {
|
||||
// Handle non-JSON responses (PDF/ZIP CE technical file, XLSX/DOCX/MD exports).
|
||||
const responseContentType = response.headers.get('content-type') || ''
|
||||
const isBinary =
|
||||
responseContentType.includes('application/pdf') ||
|
||||
responseContentType.includes('application/zip') ||
|
||||
responseContentType.includes('application/octet-stream') ||
|
||||
responseContentType.includes('application/vnd.openxmlformats-officedocument') ||
|
||||
responseContentType.includes('application/vnd.ms-excel') ||
|
||||
responseContentType.includes('application/msword') ||
|
||||
responseContentType.includes('text/markdown')
|
||||
if (isBinary) {
|
||||
const blob = await response.blob()
|
||||
const forwardedHeaders: Record<string, string> = {
|
||||
'Content-Type': responseContentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
}
|
||||
// Forward DSMS archive metadata so the frontend can render the CID badge
|
||||
// (set by archiveTechFile when the backend persisted the export to DSMS).
|
||||
for (const h of ['x-dsms-cid', 'x-dsms-filename', 'x-dsms-size']) {
|
||||
const v = response.headers.get(h)
|
||||
if (v) forwardedHeaders[h] = v
|
||||
}
|
||||
return new NextResponse(blob, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
headers: forwardedHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface BulkDiffStep {
|
||||
from: string
|
||||
from_version: string | null
|
||||
to: string
|
||||
to_version: string | null
|
||||
created_at: string | null
|
||||
kind: 'text' | 'binary'
|
||||
added_lines: number
|
||||
removed_lines: number
|
||||
metadata_diff_fields: string[]
|
||||
}
|
||||
|
||||
interface BulkDiffResponse {
|
||||
cid_latest: string
|
||||
cid_baseline: string
|
||||
versions: number
|
||||
steps: BulkDiffStep[]
|
||||
totals: {
|
||||
added_lines: number
|
||||
removed_lines: number
|
||||
metadata_fields_changed: number
|
||||
binary_steps: 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 BulkDiffPanel({ cid, onClose }: Props) {
|
||||
const [data, setData] = useState<BulkDiffResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetch(`/api/sdk/v1/dsms/documents/${encodeURIComponent(cid)}/bulk-diff`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const json = (await r.json()) as BulkDiffResponse
|
||||
if (!cancel) setData(json)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancel) setError(e?.message || 'Fehler beim Laden')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancel) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [cid])
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Aggregierter Diff: V1 → V_latest
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[11px] text-gray-500 hover:text-gray-700"
|
||||
aria-label="Bulk-Diff schliessen"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-xs text-gray-500">Bulk-Diff wird berechnet…</div>}
|
||||
{error && <div className="text-xs text-red-600 dark:text-red-400">{error}</div>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-center">
|
||||
<Stat label="Versionen" value={data.versions} tone="neutral" />
|
||||
<Stat label="Zeilen +" value={data.totals.added_lines} tone="positive" />
|
||||
<Stat label="Zeilen −" value={data.totals.removed_lines} tone="negative" />
|
||||
<Stat label="Metadaten-Felder" value={data.totals.metadata_fields_changed} tone="neutral" />
|
||||
</div>
|
||||
|
||||
{data.totals.binary_steps > 0 && (
|
||||
<div className="text-[11px] text-amber-700 dark:text-amber-400 italic">
|
||||
{data.totals.binary_steps} von {data.steps.length} Schritten binaer — Text-Diff nicht moeglich.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.steps.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic">{data.note || 'Keine Vorgaengerversion vorhanden.'}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="py-1 pr-2 font-medium">Schritt</th>
|
||||
<th className="py-1 pr-2 font-medium">Datum</th>
|
||||
<th className="py-1 pr-2 font-medium">Typ</th>
|
||||
<th className="py-1 pr-2 font-medium text-right">+</th>
|
||||
<th className="py-1 pr-2 font-medium text-right">−</th>
|
||||
<th className="py-1 font-medium">Metadaten-Felder</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.steps.map((step, i) => (
|
||||
<tr key={`${step.from}-${step.to}`} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-1 pr-2 text-gray-700 dark:text-gray-300">
|
||||
V{step.from_version || '?'} → V{step.to_version || '?'}
|
||||
<div className="text-[9px] font-mono text-gray-400">
|
||||
{shorten(step.from)} → {shorten(step.to)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-gray-500">
|
||||
{step.created_at ? new Date(step.created_at).toLocaleDateString('de-DE') : '—'}
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<span
|
||||
className={
|
||||
step.kind === 'binary'
|
||||
? 'text-amber-700 dark:text-amber-400'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{step.kind === 'binary' ? 'binaer' : 'text'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-right text-emerald-700 dark:text-emerald-400">
|
||||
{step.kind === 'binary' ? '—' : step.added_lines}
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-right text-red-700 dark:text-red-400">
|
||||
{step.kind === 'binary' ? '—' : step.removed_lines}
|
||||
</td>
|
||||
<td className="py-1 text-gray-600 dark:text-gray-400">
|
||||
{step.metadata_diff_fields.length === 0
|
||||
? '—'
|
||||
: step.metadata_diff_fields.slice(0, 3).join(', ') +
|
||||
(step.metadata_diff_fields.length > 3 ? ` (+${step.metadata_diff_fields.length - 3})` : '')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone: 'positive' | 'negative' | 'neutral' }) {
|
||||
const color =
|
||||
tone === 'positive'
|
||||
? 'text-emerald-700 dark:text-emerald-400'
|
||||
: tone === 'negative'
|
||||
? 'text-red-700 dark:text-red-400'
|
||||
: 'text-gray-800 dark:text-gray-200'
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/40 rounded p-2 border border-gray-200 dark:border-gray-700">
|
||||
<div className={`text-base font-semibold ${color}`}>{value.toLocaleString('de-DE')}</div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import BulkDiffPanel from './BulkDiffPanel'
|
||||
|
||||
interface HistoryEntry {
|
||||
cid: string
|
||||
@@ -40,6 +41,7 @@ export default function CIDHistoryModal({ cid, onClose }: Props) {
|
||||
const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null)
|
||||
const [diff, setDiff] = useState<DiffResponse | null>(null)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
const [showBulkDiff, setShowBulkDiff] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
@@ -109,9 +111,22 @@ export default function CIDHistoryModal({ cid, onClose }: Props) {
|
||||
|
||||
{!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 className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
|
||||
</div>
|
||||
{history.length > 1 && (
|
||||
<button
|
||||
onClick={() => setShowBulkDiff((v) => !v)}
|
||||
className="text-[11px] px-2 py-1 rounded border border-purple-300 text-purple-700 hover:bg-purple-50 dark:border-purple-700 dark:text-purple-300 dark:hover:bg-purple-900/30"
|
||||
title="Aggregierter Diff ueber alle Versionen"
|
||||
>
|
||||
{showBulkDiff ? 'Bulk-Diff ausblenden' : `Bulk-Diff V1 → V${history[0].version || '?'} anzeigen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showBulkDiff && <BulkDiffPanel cid={cid} onClose={() => setShowBulkDiff(false)} />}
|
||||
<ol className="relative border-l-2 border-emerald-500/40 pl-4 space-y-3">
|
||||
{history.map((entry, idx) => {
|
||||
const next = history[idx + 1]
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import CIDHistoryModal from '@/app/sdk/audit-timeline/_components/CIDHistoryModal'
|
||||
|
||||
export interface LastExport {
|
||||
cid: string
|
||||
filename: string
|
||||
size: number
|
||||
format: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lastExport: LastExport | null
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
export function ExportCIDBadge({ lastExport, onDismiss }: Props) {
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!lastExport) return null
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!lastExport) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastExport.cid)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// clipboard not available — silent
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-full bg-emerald-500 p-1 flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">
|
||||
CE-Akte exportiert und in DSMS archiviert
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-emerald-800 dark:text-emerald-300">
|
||||
{lastExport.filename} · {formatBytes(lastExport.size)} · Format {lastExport.format.toUpperCase()}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-wide text-emerald-700 dark:text-emerald-400 font-semibold">
|
||||
CID
|
||||
</span>
|
||||
<code className="font-mono text-xs text-emerald-900 dark:text-emerald-100 bg-white/60 dark:bg-black/20 px-2 py-0.5 rounded select-all break-all">
|
||||
{lastExport.cid}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="text-[11px] text-emerald-700 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-200 underline"
|
||||
title="CID in Zwischenablage kopieren"
|
||||
>
|
||||
{copied ? '✓ Kopiert' : 'Kopieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-emerald-700 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-200 underline"
|
||||
title="DSMS-Versionsverlauf und Diffs anzeigen"
|
||||
>
|
||||
Verlauf anzeigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-emerald-600 hover:text-emerald-800 dark:text-emerald-400 dark:hover:text-emerald-200 p-1 flex-shrink-0"
|
||||
title="Hinweis schliessen"
|
||||
aria-label="Hinweis schliessen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showHistory && <CIDHistoryModal cid={lastExport.cid} onClose={() => setShowHistory(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
|
||||
import { ReportGenerator } from './_components/ReportGenerator'
|
||||
import { ExportCIDBadge, type LastExport } from './_components/ExportCIDBadge'
|
||||
import { SECTION_TYPES, STATUS_CONFIG, EXPORT_FORMATS } from './_constants'
|
||||
|
||||
interface TechFileSection {
|
||||
@@ -116,6 +117,7 @@ export default function TechFilePage() {
|
||||
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [showExportMenu, setShowExportMenu] = useState(false)
|
||||
const [lastExport, setLastExport] = useState<LastExport | null>(null)
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close export menu when clicking outside
|
||||
@@ -224,6 +226,19 @@ export default function TechFilePage() {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
// DSMS archive metadata is forwarded by the backend in X-DSMS-* headers
|
||||
// when archiving succeeded. If headers are absent (DSMS gateway down)
|
||||
// the export still works but no badge is shown.
|
||||
const cid = res.headers.get('x-dsms-cid')
|
||||
if (cid) {
|
||||
setLastExport({
|
||||
cid,
|
||||
filename: res.headers.get('x-dsms-filename') || `CE-Akte-${projectId}${extension}`,
|
||||
size: parseInt(res.headers.get('x-dsms-size') || '0', 10) || blob.size,
|
||||
format,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export:', err)
|
||||
@@ -305,6 +320,9 @@ export default function TechFilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DSMS-CID badge nach erfolgreichem Export */}
|
||||
<ExportCIDBadge lastExport={lastExport} onDismiss={() => setLastExport(null)} />
|
||||
|
||||
{/* Progress */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/dsms"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -367,7 +365,10 @@ func (h *IACEHandler) ApproveTechFileSection(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ExportTechFile handles GET /projects/:id/tech-file/export?format=pdf|xlsx|docx|md|json
|
||||
// Exports all tech file sections in the requested format.
|
||||
// Exports all tech file sections in the requested format. When the archive
|
||||
// succeeds, archiveTechFile (in iace_handler_techfile_archive.go) attaches
|
||||
// X-DSMS-* response headers carrying the resulting CID so the frontend can
|
||||
// render an inline CID-badge in the export-success path.
|
||||
func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -468,31 +469,3 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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,65 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/dsms"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Side-effect: when the archive succeeds, X-DSMS-CID / X-DSMS-Filename /
|
||||
// X-DSMS-Size response headers are attached so the frontend can render an
|
||||
// inline CID-badge directly in the export-success path (no separate audit
|
||||
// query needed). Headers are written before c.Data() and survive the binary
|
||||
// blob response.
|
||||
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
|
||||
}
|
||||
|
||||
setDSMSResponseHeaders(c, result.CID, filename, result.Size)
|
||||
|
||||
if h.store == nil {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// setDSMSResponseHeaders attaches the X-DSMS-* headers so the frontend can
|
||||
// surface the archived CID inline (export-success badge) without re-querying
|
||||
// the audit trail. Pure helper — no store, no side effects beyond headers.
|
||||
func setDSMSResponseHeaders(c *gin.Context, cid, filename string, size int) {
|
||||
if cid == "" {
|
||||
return
|
||||
}
|
||||
c.Header("X-DSMS-CID", cid)
|
||||
c.Header("X-DSMS-Filename", filename)
|
||||
c.Header("X-DSMS-Size", fmt.Sprintf("%d", size))
|
||||
c.Header("Access-Control-Expose-Headers", "X-DSMS-CID, X-DSMS-Filename, X-DSMS-Size")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSetDSMSResponseHeaders_NonEmptyCID_WritesAllHeaders(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
setDSMSResponseHeaders(c, "bafytest123", "CE-Akte-FOO.pdf", 42)
|
||||
|
||||
if got := w.Header().Get("X-DSMS-CID"); got != "bafytest123" {
|
||||
t.Errorf("X-DSMS-CID: want bafytest123, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-DSMS-Filename"); got != "CE-Akte-FOO.pdf" {
|
||||
t.Errorf("X-DSMS-Filename: want CE-Akte-FOO.pdf, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-DSMS-Size"); got != "42" {
|
||||
t.Errorf("X-DSMS-Size: want 42, got %q", got)
|
||||
}
|
||||
expose := w.Header().Get("Access-Control-Expose-Headers")
|
||||
if expose == "" {
|
||||
t.Error("Access-Control-Expose-Headers should be set so the browser surfaces the X-DSMS-* headers across same-origin proxies and CORS")
|
||||
}
|
||||
for _, h := range []string{"X-DSMS-CID", "X-DSMS-Filename", "X-DSMS-Size"} {
|
||||
if !contains(expose, h) {
|
||||
t.Errorf("Access-Control-Expose-Headers missing %s, got %q", h, expose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDSMSResponseHeaders_EmptyCID_WritesNothing(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
setDSMSResponseHeaders(c, "", "irrelevant.pdf", 100)
|
||||
|
||||
if got := w.Header().Get("X-DSMS-CID"); got != "" {
|
||||
t.Errorf("X-DSMS-CID should be absent for empty CID, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-DSMS-Filename"); got != "" {
|
||||
t.Errorf("X-DSMS-Filename should be absent for empty CID, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-DSMS-Size"); got != "" {
|
||||
t.Errorf("X-DSMS-Size should be absent for empty CID, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDSMSResponseHeaders_ZeroSize_StillWritesHeader(t *testing.T) {
|
||||
// A 0-byte archive is degenerate but valid — the frontend still needs the
|
||||
// CID badge to expose the chain to the user. Don't suppress the header.
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
setDSMSResponseHeaders(c, "bafyzero", "empty.pdf", 0)
|
||||
|
||||
if got := w.Header().Get("X-DSMS-CID"); got != "bafyzero" {
|
||||
t.Errorf("X-DSMS-CID: want bafyzero, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-DSMS-Size"); got != "0" {
|
||||
t.Errorf("X-DSMS-Size: want 0, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -343,6 +343,108 @@ async def diff_documents(cid_a: str, cid_b: str):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/documents/{cid}/bulk-diff")
|
||||
async def bulk_diff_chain(cid: str):
|
||||
"""
|
||||
Aggregate diff across the entire parent_cid chain (V1 → V_latest).
|
||||
|
||||
Walks the history chain once, then computes per-step diffs between every
|
||||
chronological pair plus running totals. Designed for the "Bulk-Diff
|
||||
Report" panel in the IACE audit timeline so the user can see how a
|
||||
tech-file evolved across all versions without clicking each pair.
|
||||
"""
|
||||
history: list[dict] = []
|
||||
current_cid: Optional[str] = cid
|
||||
max_depth = 50
|
||||
|
||||
for _ in range(max_depth):
|
||||
if current_cid is None:
|
||||
break
|
||||
try:
|
||||
raw = await ipfs_cat(current_cid)
|
||||
package = json.loads(raw)
|
||||
except Exception:
|
||||
break
|
||||
metadata = package.get("metadata", {}) or {}
|
||||
history.append({
|
||||
"cid": current_cid,
|
||||
"version": metadata.get("version"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"metadata": metadata,
|
||||
"package": package,
|
||||
})
|
||||
parent = metadata.get("parent_cid")
|
||||
if not parent or parent == current_cid:
|
||||
break
|
||||
current_cid = parent
|
||||
|
||||
if len(history) < 2:
|
||||
return {
|
||||
"cid_latest": cid,
|
||||
"cid_baseline": cid,
|
||||
"versions": len(history),
|
||||
"steps": [],
|
||||
"totals": {"added_lines": 0, "removed_lines": 0, "metadata_fields_changed": 0, "binary_steps": 0},
|
||||
"note": "No predecessor versions found." if history else "CID not found.",
|
||||
}
|
||||
|
||||
# history is newest→oldest; reverse to walk chronologically.
|
||||
chronological = list(reversed(history))
|
||||
steps: list[dict] = []
|
||||
total_added = 0
|
||||
total_removed = 0
|
||||
binary_steps = 0
|
||||
fields_changed: set[str] = set()
|
||||
|
||||
for i in range(len(chronological) - 1):
|
||||
older = chronological[i]
|
||||
newer = chronological[i + 1]
|
||||
meta_diff = _diff_metadata(older["metadata"], newer["metadata"])
|
||||
text_a, text_b, is_binary = _extract_texts(older["package"], newer["package"])
|
||||
|
||||
step: dict = {
|
||||
"from": older["cid"],
|
||||
"from_version": older["version"],
|
||||
"to": newer["cid"],
|
||||
"to_version": newer["version"],
|
||||
"created_at": newer["created_at"],
|
||||
"metadata_diff_fields": sorted(meta_diff.keys()),
|
||||
}
|
||||
|
||||
if is_binary:
|
||||
step["kind"] = "binary"
|
||||
step["added_lines"] = 0
|
||||
step["removed_lines"] = 0
|
||||
binary_steps += 1
|
||||
else:
|
||||
diff_lines = list(
|
||||
_unified_diff(text_a.splitlines(), text_b.splitlines(), fromfile=older["cid"], tofile=newer["cid"], lineterm="")
|
||||
)
|
||||
added = sum(1 for ln in diff_lines if ln.startswith("+") and not ln.startswith("+++"))
|
||||
removed = sum(1 for ln in diff_lines if ln.startswith("-") and not ln.startswith("---"))
|
||||
step["kind"] = "text"
|
||||
step["added_lines"] = added
|
||||
step["removed_lines"] = removed
|
||||
total_added += added
|
||||
total_removed += removed
|
||||
|
||||
fields_changed.update(meta_diff.keys())
|
||||
steps.append(step)
|
||||
|
||||
return {
|
||||
"cid_latest": cid,
|
||||
"cid_baseline": chronological[0]["cid"],
|
||||
"versions": len(history),
|
||||
"steps": steps,
|
||||
"totals": {
|
||||
"added_lines": total_added,
|
||||
"removed_lines": total_removed,
|
||||
"metadata_fields_changed": len(fields_changed),
|
||||
"binary_steps": binary_steps,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _diff_metadata(a: dict, b: dict) -> dict:
|
||||
"""Return per-field change list: {field: {"old": ..., "new": ...}}."""
|
||||
keys = set(a.keys()) | set(b.keys())
|
||||
|
||||
@@ -106,3 +106,113 @@ async def test_history_endpoint_follows_parent_chain():
|
||||
body = resp.json()
|
||||
assert body["depth"] == 3
|
||||
assert [h["version"] for h in body["history"]] == ["3", "2", "1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_diff_aggregates_text_chain():
|
||||
"""Bulk-diff walks the chain V1→V_latest and sums per-step additions/removals."""
|
||||
|
||||
chain = {
|
||||
"v3": _wrap({"version": "3", "parent_cid": "v2"}, "alpha\nbeta\nGAMMA-CHANGED\n"),
|
||||
"v2": _wrap({"version": "2", "parent_cid": "v1"}, "alpha\nbeta\ngamma\n"),
|
||||
"v1": _wrap({"version": "1", "parent_cid": None}, "alpha\ngamma\n"),
|
||||
}
|
||||
|
||||
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/bulk-diff")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["cid_latest"] == "v3"
|
||||
assert body["cid_baseline"] == "v1"
|
||||
assert body["versions"] == 3
|
||||
assert len(body["steps"]) == 2
|
||||
|
||||
# Step ordering must be chronological (oldest pair first).
|
||||
assert body["steps"][0]["from_version"] == "1"
|
||||
assert body["steps"][0]["to_version"] == "2"
|
||||
assert body["steps"][1]["from_version"] == "2"
|
||||
assert body["steps"][1]["to_version"] == "3"
|
||||
|
||||
# All steps are text — totals must include real added/removed counts.
|
||||
assert body["totals"]["added_lines"] > 0
|
||||
assert body["totals"]["binary_steps"] == 0
|
||||
# Each step bumped the "version" metadata field — collected globally.
|
||||
assert body["totals"]["metadata_fields_changed"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_diff_single_version_returns_empty_steps():
|
||||
"""A CID without a parent_cid is a baseline — no steps to aggregate."""
|
||||
|
||||
chain = {"only": _wrap({"version": "1", "parent_cid": None}, "alpha\n")}
|
||||
|
||||
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/only/bulk-diff")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["versions"] == 1
|
||||
assert body["steps"] == []
|
||||
assert body["totals"]["added_lines"] == 0
|
||||
assert body["totals"]["binary_steps"] == 0
|
||||
assert "note" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_diff_handles_binary_step():
|
||||
"""Binary diffs cannot be line-counted — bump binary_steps, do not crash."""
|
||||
|
||||
binary = b"\xff\xfe\xfd"
|
||||
chain = {
|
||||
"v2": json.dumps({
|
||||
"metadata": {"version": "2", "parent_cid": "v1"},
|
||||
"content_base64": base64.b64encode(binary + b"\x00").decode(),
|
||||
}),
|
||||
"v1": json.dumps({
|
||||
"metadata": {"version": "1", "parent_cid": None},
|
||||
"content_base64": base64.b64encode(binary).decode(),
|
||||
}),
|
||||
}
|
||||
|
||||
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/v2/bulk-diff")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["versions"] == 2
|
||||
assert body["totals"]["binary_steps"] == 1
|
||||
assert body["steps"][0]["kind"] == "binary"
|
||||
assert body["steps"][0]["added_lines"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_diff_stops_on_fetch_error():
|
||||
"""If one CID in the chain is unreachable, return what we have — do not raise."""
|
||||
|
||||
chain = {
|
||||
"v2": _wrap({"version": "2", "parent_cid": "v1-missing"}, "alpha\n"),
|
||||
}
|
||||
|
||||
async def fake_cat(cid: str):
|
||||
if cid not in chain:
|
||||
raise RuntimeError("not pinned")
|
||||
return chain[cid]
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/v2/bulk-diff")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Only v2 was readable — chain is effectively length 1.
|
||||
assert body["versions"] == 1
|
||||
assert body["steps"] == []
|
||||
|
||||
Reference in New Issue
Block a user