Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/user_journey_test.go
Benjamin Admin 6d2de9b897
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
feat(iace): complete CE risk assessment — LLM tech-file generation, multi-format export, TipTap editor
Phase 1: Fix completeness gates G23 (require verified/rejected mitigations) and G09 (audit trail check)
Phase 2: LLM-based tech-file section generation with 19 German prompts and RAG enrichment
Phase 3: Multi-format document export (PDF/Excel/DOCX/Markdown/JSON)
Phase 4: Company profile → IACE data flow with auto component/classification creation
Phase 5: TipTap WYSIWYG editor replacing textarea for tech-file sections
Phase 6: User journey tests, developer portal API reference, updated documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:50:53 +01:00

488 lines
19 KiB
Go

package iace
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
// buildFullValidContext returns a CompletenessContext where all required gates pass
// and the project is ready for CE export.
func buildFullValidContext() *CompletenessContext {
projectID := uuid.New()
componentID1 := uuid.New()
componentID2 := uuid.New()
hazardID1 := uuid.New()
hazardID2 := uuid.New()
hazardID3 := uuid.New()
mitigationID1 := uuid.New()
mitigationID2 := uuid.New()
mitigationID3 := uuid.New()
now := time.Now()
metadata, _ := json.Marshal(map[string]interface{}{
"operating_limits": "Temperature: -10 to 50C, Humidity: 10-90% RH",
"foreseeable_misuse": "Use without protective equipment, exceeding load capacity",
})
return &CompletenessContext{
Project: &Project{
ID: projectID,
TenantID: uuid.New(),
MachineName: "CNC-Fraese ProLine 5000",
MachineType: "cnc_milling_machine",
Manufacturer: "BreakPilot Maschinenbau GmbH",
Description: "5-Achsen CNC-Fraesmaschine fuer Praezisionsfertigung im Metallbau",
Status: ProjectStatusTechFile,
CEMarkingTarget: "2023/1230",
Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
},
Components: []Component{
{
ID: componentID1,
ProjectID: projectID,
Name: "Spindelantrieb",
ComponentType: ComponentTypeMechanical,
IsSafetyRelevant: true,
Description: "Hauptspindelantrieb mit Drehzahlregelung",
CreatedAt: now,
UpdatedAt: now,
},
{
ID: componentID2,
ProjectID: projectID,
Name: "SPS Steuerung",
ComponentType: ComponentTypeController,
IsSafetyRelevant: false,
IsNetworked: true,
Description: "Programmierbare Steuerung fuer Achsenbewegung",
CreatedAt: now,
UpdatedAt: now,
},
},
Classifications: []RegulatoryClassification{
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationAIAct, ClassificationResult: "Not applicable", RiskLevel: RiskLevelNegligible, Confidence: 0.95, CreatedAt: now, UpdatedAt: now},
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationMachineryRegulation, ClassificationResult: "Annex I", RiskLevel: RiskLevelHigh, Confidence: 0.9, CreatedAt: now, UpdatedAt: now},
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationNIS2, ClassificationResult: "Not in scope", RiskLevel: RiskLevelLow, Confidence: 0.85, CreatedAt: now, UpdatedAt: now},
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationCRA, ClassificationResult: "Default category", RiskLevel: RiskLevelMedium, Confidence: 0.88, CreatedAt: now, UpdatedAt: now},
},
Hazards: []Hazard{
{ID: hazardID1, ProjectID: projectID, ComponentID: componentID1, Name: "Quetschgefahr Spindel", Category: "mechanical", Description: "Quetschgefahr beim Werkzeugwechsel", Status: HazardStatusMitigated, CreatedAt: now, UpdatedAt: now},
{ID: hazardID2, ProjectID: projectID, ComponentID: componentID1, Name: "Schnittverletzung", Category: "mechanical", Description: "Schnittverletzung durch rotierende Fraeser", Status: HazardStatusMitigated, CreatedAt: now, UpdatedAt: now},
{ID: hazardID3, ProjectID: projectID, ComponentID: componentID2, Name: "Elektrischer Schlag", Category: "electrical", Description: "Kontakt mit spannungsfuehrenden Teilen", Status: HazardStatusAccepted, CreatedAt: now, UpdatedAt: now},
},
Assessments: []RiskAssessment{
{ID: uuid.New(), HazardID: hazardID1, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 3, Exposure: 2, Probability: 2, InherentRisk: 12, ControlMaturity: 4, ControlCoverage: 0.85, TestEvidenceStrength: 0.8, CEff: 0.75, ResidualRisk: 3.0, RiskLevel: RiskLevelLow, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
{ID: uuid.New(), HazardID: hazardID2, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 2, Exposure: 2, Probability: 1, InherentRisk: 4, ControlMaturity: 3, ControlCoverage: 0.9, TestEvidenceStrength: 0.7, CEff: 0.8, ResidualRisk: 0.8, RiskLevel: RiskLevelNegligible, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
{ID: uuid.New(), HazardID: hazardID3, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 2, Exposure: 1, Probability: 1, InherentRisk: 2, ControlMaturity: 4, ControlCoverage: 0.95, TestEvidenceStrength: 0.9, CEff: 0.9, ResidualRisk: 0.2, RiskLevel: RiskLevelNegligible, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
},
Mitigations: []Mitigation{
{ID: mitigationID1, HazardID: hazardID1, ReductionType: ReductionTypeDesign, Name: "Schutzhaube mit Verriegelung", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodTest, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
{ID: mitigationID2, HazardID: hazardID2, ReductionType: ReductionTypeProtective, Name: "Lichtschranke Arbeitsbereich", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodInspection, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
{ID: mitigationID3, HazardID: hazardID3, ReductionType: ReductionTypeInformation, Name: "Warnhinweis Hochspannung", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodReview, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
},
Evidence: []Evidence{
{ID: uuid.New(), ProjectID: projectID, MitigationID: &mitigationID1, FileName: "pruefbericht_schutzhaube.pdf", FilePath: "/evidence/pruefbericht_schutzhaube.pdf", FileHash: "sha256:abc123", FileSize: 524288, MimeType: "application/pdf", Description: "Pruefbericht Schutzhaubenverriegelung", UploadedBy: uuid.New(), CreatedAt: now},
{ID: uuid.New(), ProjectID: projectID, MitigationID: &mitigationID2, FileName: "lichtschranke_abnahme.pdf", FilePath: "/evidence/lichtschranke_abnahme.pdf", FileHash: "sha256:def456", FileSize: 1048576, MimeType: "application/pdf", Description: "Abnahmeprotokoll Lichtschranke", UploadedBy: uuid.New(), CreatedAt: now},
},
TechFileSections: []TechFileSection{
{ID: uuid.New(), ProjectID: projectID, SectionType: "risk_assessment_report", Title: "Risikobeurteilung nach ISO 12100", Content: "Vollstaendige Risikobeurteilung der CNC-Fraese...", Version: 1, Status: TechFileSectionStatusApproved, CreatedAt: now, UpdatedAt: now},
{ID: uuid.New(), ProjectID: projectID, SectionType: "hazard_log_combined", Title: "Gefaehrdungsprotokoll", Content: "Protokoll aller identifizierten Gefaehrdungen...", Version: 1, Status: TechFileSectionStatusApproved, CreatedAt: now, UpdatedAt: now},
},
HasAI: false,
PatternMatchingPerformed: true,
}
}
// findGate searches through a CompletenessResult for a gate with the given ID.
// Returns the gate and true if found, zero-value gate and false otherwise.
func findGate(result CompletenessResult, gateID string) (CompletenessGate, bool) {
for _, g := range result.Gates {
if g.ID == gateID {
return g, true
}
}
return CompletenessGate{}, false
}
// ============================================================================
// Test 1: Full User Journey
// ============================================================================
func TestCEWorkflow_FullUserJourney(t *testing.T) {
ctx := buildFullValidContext()
checker := NewCompletenessChecker()
// Step 1: Verify completeness check passes all required gates
result := checker.Check(ctx)
if !result.CanExport {
t.Error("CanExport should be true for fully valid project")
for _, g := range result.Gates {
if g.Required && !g.Passed {
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
}
}
}
if result.PassedRequired != result.TotalRequired {
t.Errorf("PassedRequired = %d, TotalRequired = %d; want all required gates to pass",
result.PassedRequired, result.TotalRequired)
}
// All required gates should individually pass
for _, g := range result.Gates {
if g.Required && !g.Passed {
t.Errorf("Required gate %s (%s) did not pass: %s", g.ID, g.Label, g.Details)
}
}
// Step 2: Export PDF and verify output
exporter := NewDocumentExporter()
pdfData, err := exporter.ExportPDF(
ctx.Project,
ctx.TechFileSections,
ctx.Hazards,
ctx.Assessments,
ctx.Mitigations,
ctx.Classifications,
)
if err != nil {
t.Fatalf("ExportPDF returned error: %v", err)
}
if len(pdfData) == 0 {
t.Fatal("ExportPDF returned empty bytes")
}
if !bytes.HasPrefix(pdfData, []byte("%PDF-")) {
t.Errorf("PDF output does not start with %%PDF-, got first 10 bytes: %q", pdfData[:min(10, len(pdfData))])
}
// Step 3: Export Excel and verify output
xlsxData, err := exporter.ExportExcel(
ctx.Project,
ctx.TechFileSections,
ctx.Hazards,
ctx.Assessments,
ctx.Mitigations,
)
if err != nil {
t.Fatalf("ExportExcel returned error: %v", err)
}
if len(xlsxData) == 0 {
t.Fatal("ExportExcel returned empty bytes")
}
if !bytes.HasPrefix(xlsxData, []byte("PK")) {
t.Errorf("Excel output does not start with PK (zip signature), got first 4 bytes: %x", xlsxData[:min(4, len(xlsxData))])
}
// Step 4: Export Markdown and verify output contains section titles
mdData, err := exporter.ExportMarkdown(ctx.Project, ctx.TechFileSections)
if err != nil {
t.Fatalf("ExportMarkdown returned error: %v", err)
}
if len(mdData) == 0 {
t.Fatal("ExportMarkdown returned empty bytes")
}
mdContent := string(mdData)
for _, section := range ctx.TechFileSections {
if !strings.Contains(mdContent, section.Title) {
t.Errorf("Markdown output missing section title %q", section.Title)
}
}
if !strings.Contains(mdContent, ctx.Project.MachineName) {
t.Errorf("Markdown output missing project name %q", ctx.Project.MachineName)
}
// Step 5: Export DOCX and verify output
docxData, err := exporter.ExportDOCX(ctx.Project, ctx.TechFileSections)
if err != nil {
t.Fatalf("ExportDOCX returned error: %v", err)
}
if len(docxData) == 0 {
t.Fatal("ExportDOCX returned empty bytes")
}
if !bytes.HasPrefix(docxData, []byte("PK")) {
t.Errorf("DOCX output does not start with PK (zip signature), got first 4 bytes: %x", docxData[:min(4, len(docxData))])
}
}
// ============================================================================
// Test 2: High Risk Not Acceptable blocks export
// ============================================================================
func TestCEWorkflow_HighRiskNotAcceptable(t *testing.T) {
ctx := buildFullValidContext()
// Override: make one hazard high-risk and not acceptable
hazardID := ctx.Hazards[0].ID
ctx.Assessments[0] = RiskAssessment{
ID: uuid.New(),
HazardID: hazardID,
Version: 2,
AssessmentType: AssessmentTypePostMitigation,
Severity: 5,
Exposure: 4,
Probability: 4,
InherentRisk: 80,
ControlMaturity: 2,
ControlCoverage: 0.3,
CEff: 0.2,
ResidualRisk: 64,
RiskLevel: RiskLevelHigh,
IsAcceptable: false,
AssessedBy: uuid.New(),
CreatedAt: time.Now(),
}
checker := NewCompletenessChecker()
result := checker.Check(ctx)
if result.CanExport {
t.Error("CanExport should be false when a high-risk hazard is not acceptable")
}
// Verify G24 (residual risk accepted) specifically fails
g24, found := findGate(result, "G24")
if !found {
t.Fatal("G24 gate not found in results")
}
if g24.Passed {
t.Error("G24 should fail when a hazard has RiskLevelHigh and IsAcceptable=false")
}
}
// ============================================================================
// Test 3: Incomplete mitigations block export
// ============================================================================
func TestCEWorkflow_IncompleteMitigationsBlockExport(t *testing.T) {
ctx := buildFullValidContext()
// Override: set mitigations to planned status (not yet verified)
for i := range ctx.Mitigations {
ctx.Mitigations[i].Status = MitigationStatusPlanned
}
checker := NewCompletenessChecker()
result := checker.Check(ctx)
if result.CanExport {
t.Error("CanExport should be false when mitigations are in planned status")
}
// Verify G23 (mitigations verified) specifically fails
g23, found := findGate(result, "G23")
if !found {
t.Fatal("G23 gate not found in results")
}
if g23.Passed {
t.Error("G23 should fail when mitigations are still in planned status")
}
}
// ============================================================================
// Test 4: Mitigation hierarchy warning (information-only still allows export)
// ============================================================================
func TestCEWorkflow_MitigationHierarchyWarning(t *testing.T) {
ctx := buildFullValidContext()
// Override: set all mitigations to information type only (no design or protective)
for i := range ctx.Mitigations {
ctx.Mitigations[i].ReductionType = ReductionTypeInformation
ctx.Mitigations[i].Status = MitigationStatusVerified
}
checker := NewCompletenessChecker()
result := checker.Check(ctx)
// Information-only mitigations are advisory; no gate blocks this scenario.
// The project should still be exportable.
if !result.CanExport {
t.Error("CanExport should be true even with information-only mitigations (advisory, not gated)")
for _, g := range result.Gates {
if g.Required && !g.Passed {
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
}
}
}
// Verify all required gates still pass
if result.PassedRequired != result.TotalRequired {
t.Errorf("PassedRequired = %d, TotalRequired = %d; want all required gates to pass with information-only mitigations",
result.PassedRequired, result.TotalRequired)
}
}
// ============================================================================
// Test 5: AI components require extra tech file sections
// ============================================================================
func TestCEWorkflow_AIComponentsExtraSections(t *testing.T) {
checker := NewCompletenessChecker()
t.Run("AI without AI tech file sections fails G42", func(t *testing.T) {
ctx := buildFullValidContext()
ctx.HasAI = true
// Add AI Act classification (needed for G06 to pass with HasAI=true)
for i := range ctx.Classifications {
if ctx.Classifications[i].Regulation == RegulationAIAct {
ctx.Classifications[i].ClassificationResult = "High Risk"
ctx.Classifications[i].RiskLevel = RiskLevelHigh
}
}
// TechFileSections has risk_assessment_report and hazard_log_combined but no AI sections
result := checker.Check(ctx)
g42, found := findGate(result, "G42")
if !found {
t.Fatal("G42 gate not found in results")
}
if g42.Passed {
t.Error("G42 should fail when HasAI=true but AI tech file sections are missing")
}
if result.CanExport {
t.Error("CanExport should be false when G42 fails")
}
})
t.Run("AI with AI tech file sections passes G42", func(t *testing.T) {
ctx := buildFullValidContext()
ctx.HasAI = true
// Add AI Act classification
for i := range ctx.Classifications {
if ctx.Classifications[i].Regulation == RegulationAIAct {
ctx.Classifications[i].ClassificationResult = "High Risk"
ctx.Classifications[i].RiskLevel = RiskLevelHigh
}
}
// Add the required AI tech file sections
now := time.Now()
ctx.TechFileSections = append(ctx.TechFileSections,
TechFileSection{
ID: uuid.New(),
ProjectID: ctx.Project.ID,
SectionType: "ai_intended_purpose",
Title: "KI-Zweckbestimmung",
Content: "Bestimmungsgemaesse Verwendung des KI-Systems...",
Version: 1,
Status: TechFileSectionStatusApproved,
CreatedAt: now,
UpdatedAt: now,
},
TechFileSection{
ID: uuid.New(),
ProjectID: ctx.Project.ID,
SectionType: "ai_model_description",
Title: "KI-Modellbeschreibung",
Content: "Beschreibung des verwendeten KI-Modells...",
Version: 1,
Status: TechFileSectionStatusApproved,
CreatedAt: now,
UpdatedAt: now,
},
)
result := checker.Check(ctx)
g42, found := findGate(result, "G42")
if !found {
t.Fatal("G42 gate not found in results")
}
if !g42.Passed {
t.Errorf("G42 should pass when HasAI=true and both AI tech file sections are present; details: %s", g42.Details)
}
if !result.CanExport {
t.Error("CanExport should be true when all gates pass including G42 with AI sections")
for _, g := range result.Gates {
if g.Required && !g.Passed {
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
}
}
}
})
}
// ============================================================================
// Test 6: Export with empty/minimal project data
// ============================================================================
func TestCEWorkflow_ExportEmptyProject(t *testing.T) {
exporter := NewDocumentExporter()
minimalProject := &Project{
ID: uuid.New(),
TenantID: uuid.New(),
MachineName: "Leeres Testprojekt",
MachineType: "test",
Manufacturer: "TestCorp",
Status: ProjectStatusDraft,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
t.Run("PDF export with empty project succeeds", func(t *testing.T) {
data, err := exporter.ExportPDF(minimalProject, nil, nil, nil, nil, nil)
if err != nil {
t.Fatalf("ExportPDF returned error for empty project: %v", err)
}
if len(data) == 0 {
t.Fatal("ExportPDF returned empty bytes for empty project")
}
if !bytes.HasPrefix(data, []byte("%PDF-")) {
t.Errorf("PDF output does not start with %%PDF-, got first 10 bytes: %q", data[:min(10, len(data))])
}
})
t.Run("Excel export with empty project succeeds", func(t *testing.T) {
data, err := exporter.ExportExcel(minimalProject, nil, nil, nil, nil)
if err != nil {
t.Fatalf("ExportExcel returned error for empty project: %v", err)
}
if len(data) == 0 {
t.Fatal("ExportExcel returned empty bytes for empty project")
}
if !bytes.HasPrefix(data, []byte("PK")) {
t.Errorf("Excel output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
}
})
t.Run("Markdown export with empty project succeeds", func(t *testing.T) {
data, err := exporter.ExportMarkdown(minimalProject, nil)
if err != nil {
t.Fatalf("ExportMarkdown returned error for empty project: %v", err)
}
if len(data) == 0 {
t.Fatal("ExportMarkdown returned empty bytes for empty project")
}
mdContent := string(data)
if !strings.Contains(mdContent, minimalProject.MachineName) {
t.Errorf("Markdown output missing project name %q", minimalProject.MachineName)
}
if !strings.Contains(mdContent, "#") {
t.Error("Markdown output missing header markers")
}
})
t.Run("DOCX export with empty project succeeds", func(t *testing.T) {
data, err := exporter.ExportDOCX(minimalProject, nil)
if err != nil {
t.Fatalf("ExportDOCX returned error for empty project: %v", err)
}
if len(data) == 0 {
t.Fatal("ExportDOCX returned empty bytes for empty project")
}
if !bytes.HasPrefix(data, []byte("PK")) {
t.Errorf("DOCX output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
}
})
}