From 2b1fe3713a7a5afa4032248eb1ca95d62b34cc9a Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 22 May 2026 10:02:18 +0200 Subject: [PATCH] feat(dsms): tech-file DSMS archive now logs CID into IACE audit trail Before: archiveTechFile called dsms.Archive() and discarded the result. The file was archived to IPFS but no audit-trail entry was written, so there was no way to later prove "this CE-Akte export went to DSMS with CID X". After: - archiveTechFile is now a method on IACEHandler with access to store + gin context, and captures the CID from dsms.Archive(). - Writes an AuditAction "tech_file_export" audit entry whose new_values JSON carries {cid, filename, size}, mirroring the Python evidence-upload pattern. - Applies to PDF, XLSX, DOCX, and Markdown exports. Plus dsms package gets 3 unit tests pinning the contract: success-CID extraction, gateway-unreachable returns nil, 500-response returns nil. This closes DSMS Stufe 2 (evidence side was already wired; tech-file side was missing the audit hook). Stufe 3 next: version chains + delta view. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/handlers/iace_handler_techfile.go | 38 ++++++++-- .../internal/dsms/client_test.go | 74 +++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 ai-compliance-sdk/internal/dsms/client_test.go diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go index 909a8b34..6df3f194 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_techfile.go @@ -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, + ) } diff --git a/ai-compliance-sdk/internal/dsms/client_test.go b/ai-compliance-sdk/internal/dsms/client_test.go new file mode 100644 index 00000000..4211a5f7 --- /dev/null +++ b/ai-compliance-sdk/internal/dsms/client_test.go @@ -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) + } +}