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))]) } }) }