package security import ( "context" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) // TestFindingRequiresAllFourFields prueft die Pflichtfelder eines Befunds. // // Der Plan (§17) verlangt Schweregrad, Erklaerung, betroffenes Objekt und // Empfehlung. Der letzte Punkt ist der wichtigste: Ein Befund ohne Empfehlung // ist eine Beunruhigung — er sagt, dass etwas nicht stimmt, und laesst den // Betreiber damit allein. func TestFindingRequiresAllFourFields(testInstance *testing.T) { completeFinding := Finding{ Area: AreaMultiFactor, Severity: SeverityHigh, Title: "Konten ohne zweiten Faktor", Explanation: "Ein Passwort allein haelt niemanden auf, der es kennt.", AffectedObject: "2 von 3 Konten", Recommendation: "Richten Sie den zweiten Faktor ein.", } if validationError := completeFinding.Validate(); validationError != nil { testInstance.Fatalf("ein vollstaendiger Befund wurde abgelehnt: %v", validationError) } incompleteVariants := map[string]Finding{ "ohne Ueberschrift": {Area: "x", Explanation: "e", AffectedObject: "o", Recommendation: "r"}, "ohne Erklaerung": {Area: "x", Title: "t", AffectedObject: "o", Recommendation: "r"}, "ohne Objekt": {Area: "x", Title: "t", Explanation: "e", Recommendation: "r"}, "ohne Empfehlung": {Area: "x", Title: "t", Explanation: "e", AffectedObject: "o"}, } for variantName, incompleteFinding := range incompleteVariants { if validationError := incompleteFinding.Validate(); validationError == nil { testInstance.Errorf("ein Befund %s wurde angenommen", variantName) } } } // TestUncheckedAreasDoNotCountAsPassed ist der Kerntest der Bewertung. // // Ein Bereich, der nicht geprueft werden konnte, geht **nicht** in die Rechnung // ein — weder positiv noch negativ. Ihn als bestanden zu werten waere // Schoenfaerberei, ihn als durchgefallen zu werten eine Behauptung. func TestUncheckedAreasDoNotCountAsPassed(testInstance *testing.T) { assessment := &Assessment{ Areas: []AreaResult{ {Area: "a", Title: "Geprueft", Available: true, Weight: 20, EarnedPoints: 20}, { Area: "b", Title: "Nicht pruefbar", Available: false, Weight: 30, UnavailableReason: "Die Funktion gibt es nicht.", }, }, UncheckedAreas: make([]string, 0, 1), } finalizeAssessment(assessment) // 20 von 20 möglichen Punkten der **geprueften** Bereiche: 100 Prozent. // Waere der ungeprueft Bereich als durchgefallen gezaehlt worden, staende // hier 40 Prozent — eine Zahl, die eine Aussage ueber etwas macht, das // niemand geprueft hat. if assessment.Score != 100 { testInstance.Errorf("die Bewertung ist %d Prozent, erwartet wurden 100", assessment.Score) } if assessment.MaximumScore != 20 { testInstance.Errorf("die erreichbaren Punkte sind %d, erwartet wurden 20", assessment.MaximumScore) } if assessment.UncheckedAreaCount != 1 { testInstance.Errorf("%d Bereiche gelten als ungeprueft, erwartet wurde einer", assessment.UncheckedAreaCount) } // Der ungeprueft Bereich muss namentlich erscheinen — er ist die // Handlungsanweisung. if len(assessment.UncheckedAreas) != 1 || assessment.UncheckedAreas[0] != "Nicht pruefbar" { testInstance.Errorf("die ungeprueften Bereiche wurden nicht benannt: %v", assessment.UncheckedAreas) } } // TestCriticalFindingCapsGrade prueft die Deckelung bei einem kritischen Befund. // // Dieselbe Regel wie bei der Recovery Assurance (Phase 10): Ein beschaedigtes // Backup ist nicht zu 70 Prozent wiederherstellbar. Eine Anlage, bei der ein // gestohlenes Konto alle Backups vernichten kann, ist nicht „gut abgesichert // mit kleinem Mangel". func TestCriticalFindingCapsGrade(testInstance *testing.T) { assessment := &Assessment{ Areas: []AreaResult{ {Area: "a", Title: "Fast alles gut", Available: true, Weight: 90, EarnedPoints: 88}, { Area: "b", Title: "Ein kritischer Punkt", Available: true, Weight: 10, EarnedPoints: 0, Findings: []Finding{{ Area: "b", Severity: SeverityCritical, Title: "Kein Loeschschutz", Explanation: "Alles loeschbar.", AffectedObject: "alle Repositories", Recommendation: "Gehaertetes Repository anlegen.", }}, }, }, UncheckedAreas: make([]string, 0), } finalizeAssessment(assessment) if assessment.Score < 80 { testInstance.Fatalf("die Bewertung ist %d Prozent; der Test prueft damit nichts", assessment.Score) } if assessment.Grade != "unzureichend" { testInstance.Errorf("die Einstufung ist %q trotz kritischem Befund", assessment.Grade) } if assessment.IsTrustworthy() { testInstance.Error("eine Lage mit kritischem Befund gilt als vertrauenswuerdig") } // Die Zusammenfassung muss sagen, dass die Prozentzahl nichts daran aendert. if !strings.Contains(assessment.Summary, "kritische") { testInstance.Errorf("die Zusammenfassung nennt den kritischen Befund nicht: %q", assessment.Summary) } } // TestManyUncheckedAreasMakeScoreAGuess prueft den Hinweis bei duenner Grundlage. func TestManyUncheckedAreasMakeScoreAGuess(testInstance *testing.T) { assessment := &Assessment{ Areas: []AreaResult{ {Area: "a", Title: "Geprueft", Available: true, Weight: 10, EarnedPoints: 10}, {Area: "b", Title: "Ungeprueft 1", Available: false, Weight: 10}, {Area: "c", Title: "Ungeprueft 2", Available: false, Weight: 10}, {Area: "d", Title: "Ungeprueft 3", Available: false, Weight: 10}, }, UncheckedAreas: make([]string, 0, 3), } finalizeAssessment(assessment) if assessment.IsTrustworthy() { testInstance.Error("eine Bewertung aus einem von vier Bereichen gilt als belastbar") } if !strings.Contains(assessment.Summary, "Vermutung") { testInstance.Errorf("die Zusammenfassung ordnet die duenne Grundlage nicht ein: %q", assessment.Summary) } } // TestFindingsAreOrderedBySeverity prueft die Reihenfolge der Befunde. // // Wer die Liste oeffnet, soll oben lesen, was zuerst zu tun ist. func TestFindingsAreOrderedBySeverity(testInstance *testing.T) { assessment := &Assessment{ Areas: []AreaResult{ { Area: "a", Available: true, Findings: []Finding{ {Severity: SeverityWarning, Title: "Warnung"}, {Severity: SeverityCritical, Title: "Kritisch"}, }, }, { Area: "b", Available: true, Findings: []Finding{{Severity: SeverityHigh, Title: "Hoch"}}, }, }, } orderedFindings := assessment.AllFindings() if len(orderedFindings) != 3 { testInstance.Fatalf("%d Befunde geliefert, erwartet wurden 3", len(orderedFindings)) } expectedOrder := []string{"Kritisch", "Hoch", "Warnung"} for findingIndex, expectedTitle := range expectedOrder { if orderedFindings[findingIndex].Title != expectedTitle { testInstance.Errorf("an Stelle %d steht %q, erwartet wurde %q", findingIndex, orderedFindings[findingIndex].Title, expectedTitle) } } } // connectTestDatabase oeffnet die Testdatenbank. func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool { testInstance.Helper() connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL") if connectionString == "" { testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden uebersprungen") } connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second) defer cancelConnect() connectionPool, poolError := pgxpool.New(connectContext, connectionString) if poolError != nil { testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError) } if pingError := connectionPool.Ping(connectContext); pingError != nil { connectionPool.Close() testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError) } testInstance.Cleanup(connectionPool.Close) return connectionPool } // TestInspectionCoversEveryPlannedArea prueft die Vollstaendigkeit gegen die Datenbank. // // Der Plan (§17) nennt zehn Bereiche. Fehlt einer, ist er nicht „noch nicht // gebaut", sondern vergessen — und niemand sieht es. func TestInspectionCoversEveryPlannedArea(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) inspector := NewInspector(connectionPool, "0.1.0-test") assessment, inspectError := inspector.Inspect(context.Background()) if inspectError != nil { testInstance.Fatalf("die Pruefung schlug fehl: %v", inspectError) } if len(assessment.Areas) != 10 { testInstance.Errorf("%d Bereiche geprueft, der Plan nennt zehn", len(assessment.Areas)) } expectedAreas := []string{ AreaMultiFactor, AreaEncryption, AreaImmutability, AreaOffsite, AreaAudit, AreaCertificates, AreaAgentSecurity, AreaUpdates, AreaRansomwareRisk, AreaAccessControl, } presentAreas := make(map[string]bool, len(assessment.Areas)) for _, areaResult := range assessment.Areas { presentAreas[areaResult.Area] = true if areaResult.Title == "" || areaResult.Summary == "" { testInstance.Errorf("der Bereich %s ist nicht beschrieben", areaResult.Area) } // Ein nicht pruefbarer Bereich muss sagen, warum. if !areaResult.Available && areaResult.UnavailableReason == "" { testInstance.Errorf("der Bereich %s ist nicht pruefbar und erklaert sich nicht", areaResult.Area) } } for _, expectedArea := range expectedAreas { if !presentAreas[expectedArea] { testInstance.Errorf("der Bereich %s fehlt in der Pruefung", expectedArea) } } } // TestEveryRealFindingIsComplete prueft die Befunde gegen die echte Datenbank. // // Die Pruefung selbst lehnt unvollstaendige Befunde ab; dieser Test stellt // sicher, dass sie es auch mit echten Daten tut — dort entstehen die Befunde // aus Zeichenkettenformatierung, und ein leeres Feld faellt sonst nicht auf. func TestEveryRealFindingIsComplete(testInstance *testing.T) { connectionPool := connectTestDatabase(testInstance) inspector := NewInspector(connectionPool, "0.1.0-test") assessment, inspectError := inspector.Inspect(context.Background()) if inspectError != nil { testInstance.Fatalf("die Pruefung schlug fehl: %v", inspectError) } allFindings := assessment.AllFindings() for _, finding := range allFindings { if validationError := finding.Validate(); validationError != nil { testInstance.Errorf("ein echter Befund ist unvollstaendig: %v", validationError) } // Eine Empfehlung, die nur den Befund wiederholt, hilft niemandem. if len(finding.Recommendation) < 20 { testInstance.Errorf("die Empfehlung zu %q ist zu knapp: %q", finding.Title, finding.Recommendation) } } }