package verification import ( "strings" "testing" "time" ) // referenceNow ist der feste Bezugszeitpunkt der Bewertungstests. // // Ein fester Zeitpunkt statt time.Now(): Sonst haengt das Ergebnis davon ab, // wann der Test laeuft. var referenceNow = time.Date(2026, time.June, 15, 12, 0, 0, 0, time.UTC) // timePointer liefert einen Zeiger auf einen Zeitpunkt. func timePointer(pointInTime time.Time) *time.Time { return &pointInTime } // buildIdealFacts liefert Tatsachen eines vorbildlich betreuten Backups. func buildIdealFacts() BackupFacts { return BackupFacts{ RunSucceeded: true, CompletedAt: timePointer(referenceNow.Add(-2 * time.Hour)), LastVerifiedAt: timePointer(referenceNow.Add(-24 * time.Hour)), LastRestoreTestAt: timePointer(referenceNow.Add(-7 * 24 * time.Hour)), IsEncrypted: true, IsImmutable: true, HasOffsiteCopy: true, RepositoryHealthy: true, RecoveryPointObjective: 24 * time.Hour, RecoveryTimeObjective: 4 * time.Hour, MeasuredRestoreDuration: 30 * time.Minute, } } // findInput sucht eine Eingangsgroesse anhand ihres Namens. func findInput(score *AssuranceScore, inputName string) *ScoreInput { for inputIndex := range score.Inputs { if score.Inputs[inputIndex].Name == inputName { return &score.Inputs[inputIndex] } } return nil } // TestClassificationSeparatesSuccessFromRecoverable ist der wichtigste Test // dieser Datei. // // Dass ein Sicherungslauf durchlief, sagt nichts darueber, ob sich die Daten // zurueckholen lassen. Genau diese Verwechslung soll die Einstufung unmoeglich // machen. func TestClassificationSeparatesSuccessFromRecoverable(testInstance *testing.T) { // Nur gesichert, nie geprueft. justBackedUp := BackupFacts{RunSucceeded: true, CompletedAt: timePointer(referenceNow)} if classification := Classify(justBackedUp); classification != ClassificationSuccessful { testInstance.Errorf("ein ungepruefes Backup wurde als %q eingestuft", classification) } // Geprueft, aber nie zurueckgeschrieben. verifiedOnly := justBackedUp verifiedOnly.LastVerifiedAt = timePointer(referenceNow) if classification := Classify(verifiedOnly); classification != ClassificationVerified { testInstance.Errorf("ein geprueftes Backup wurde als %q eingestuft", classification) } // Erst der Test macht es wiederherstellbar. restoreTested := verifiedOnly restoreTested.LastRestoreTestAt = timePointer(referenceNow) if classification := Classify(restoreTested); classification != ClassificationRecoverable { testInstance.Errorf("ein erprobtes Backup wurde als %q eingestuft", classification) } // Die Leiter muss aufsteigend sein. if ClassificationSuccessful.Rank() >= ClassificationVerified.Rank() || ClassificationVerified.Rank() >= ClassificationRecoverable.Rank() { testInstance.Error("die Einstufungen bilden keine aufsteigende Leiter") } } // TestCorruptionOverridesEverything prueft den Vorrang der Beschaedigung. // // Ein Backup, das erfolgreich gesichert und beschaedigt ist, ist beschaedigt. func TestCorruptionOverridesEverything(testInstance *testing.T) { corruptedFacts := buildIdealFacts() corruptedFacts.VerificationFoundCorruption = true if classification := Classify(corruptedFacts); classification != ClassificationCorrupted { testInstance.Fatalf("ein beschaedigtes Backup wurde als %q eingestuft", classification) } // Auch ein fehlgeschlagener Wiederherstellungstest bedeutet beschaedigt: // Wenn es sich nicht zurueckholen laesst, ist es unbrauchbar. failedTestFacts := buildIdealFacts() failedTestFacts.RestoreTestFailed = true if classification := Classify(failedTestFacts); classification != ClassificationCorrupted { testInstance.Errorf("ein fehlgeschlagener Test ergab die Einstufung %q", classification) } // Fehlende Bloecke ebenso: Was fehlt, laesst sich nicht zurueckholen. missingFacts := buildIdealFacts() missingFacts.VerificationFoundMissing = true if classification := Classify(missingFacts); classification != ClassificationCorrupted { testInstance.Errorf("fehlende Bloecke ergaben die Einstufung %q", classification) } } // TestFailedRunIsNeverPositive prueft den gescheiterten Lauf. func TestFailedRunIsNeverPositive(testInstance *testing.T) { failedFacts := buildIdealFacts() failedFacts.RunSucceeded = false if classification := Classify(failedFacts); classification != ClassificationFailed { testInstance.Fatalf("ein gescheiterter Lauf wurde als %q eingestuft", classification) } } // TestUnknownNeverCountsAsGood ist der zentrale Ehrlichkeitstest der Bewertung. // // Eine Bewertung, die ohne Messung hoch ausfaellt, waere das schlimmste // Fake-Feature der Anlage — sie beruhigte genau dort, wo man hinsehen muesste. func TestUnknownNeverCountsAsGood(testInstance *testing.T) { // Ein Backup, ueber das nichts bekannt ist ausser dass es gelang. unmeasuredFacts := BackupFacts{ RunSucceeded: true, CompletedAt: timePointer(referenceNow.Add(-time.Hour)), RepositoryHealthy: true, } unmeasuredScore := CalculateScore(unmeasuredFacts, referenceNow) // Die Pruefung und der Test sind zusammen 45 der 100 Punkte. Ohne sie darf // die Bewertung nicht in die Naehe einer Beruhigung kommen. if unmeasuredScore.Percentage > 55 { testInstance.Fatalf("ein nie geprueftes Backup erreichte %d %%; die Bewertung beruhigt ohne Grund", unmeasuredScore.Percentage) } verificationInput := findInput(unmeasuredScore, "Integritaetspruefung") if verificationInput == nil { testInstance.Fatal("die Integritaetspruefung fehlt in der Bewertung") } if verificationInput.IsKnown { testInstance.Error("eine nie durchgefuehrte Pruefung wurde als gemessen gefuehrt") } if verificationInput.EarnedPoints != 0 { testInstance.Errorf("eine nie durchgefuehrte Pruefung erhielt %d Punkte", verificationInput.EarnedPoints) } restoreInput := findInput(unmeasuredScore, "Wiederherstellungstest") if restoreInput == nil || restoreInput.IsKnown || restoreInput.EarnedPoints != 0 { testInstance.Error("ein nie durchgefuehrter Wiederherstellungstest wurde nicht als ungemessen gefuehrt") } // Und die Bewertung sagt selbst, dass sie nicht belastbar ist. if unmeasuredScore.IsTrustworthy() { testInstance.Errorf("eine Bewertung mit %d ungemessenen Groessen gilt als belastbar", unmeasuredScore.UnknownInputCount) } if !strings.Contains(unmeasuredScore.Summary(), "Vermutung") { testInstance.Errorf("die Zusammenfassung verschweigt die fehlenden Messungen: %q", unmeasuredScore.Summary()) } } // TestIdealBackupScoresHigh prueft den Gegenpol. func TestIdealBackupScoresHigh(testInstance *testing.T) { idealScore := CalculateScore(buildIdealFacts(), referenceNow) if idealScore.Percentage < 95 { testInstance.Errorf("ein vorbildlich betreutes Backup erreichte nur %d %%: %+v", idealScore.Percentage, idealScore.Inputs) } if idealScore.Classification != ClassificationRecoverable { testInstance.Errorf("das vorbildliche Backup wurde als %q eingestuft", idealScore.Classification) } if !idealScore.IsTrustworthy() { testInstance.Errorf("die Bewertung gilt als nicht belastbar: %d ungemessene Groessen", idealScore.UnknownInputCount) } if len(idealScore.MissingMeasurements()) != 0 { testInstance.Errorf("es fehlen angeblich Messungen: %v", idealScore.MissingMeasurements()) } } // TestMissingMeasurementsAreNamed prueft die Handlungsanweisung. // // Was hier steht, sollte als Naechstes geprueft werden — eine Bewertung ohne // diese Auskunft liesse den Betreiber raten. func TestMissingMeasurementsAreNamed(testInstance *testing.T) { sparseFacts := BackupFacts{ RunSucceeded: true, CompletedAt: timePointer(referenceNow), RepositoryHealthy: true, } sparseScore := CalculateScore(sparseFacts, referenceNow) missingNames := sparseScore.MissingMeasurements() if len(missingNames) == 0 { testInstance.Fatal("es wurden keine fehlenden Messungen benannt") } joinedNames := strings.Join(missingNames, " ") for _, expectedName := range []string{"Integritaetspruefung", "Wiederherstellungstest"} { if !strings.Contains(joinedNames, expectedName) { testInstance.Errorf("die fehlende Messung %q wurde nicht benannt: %v", expectedName, missingNames) } } } // TestStaleVerificationLosesValue prueft die Alterung einer Pruefung. // // Eine Pruefung vom letzten Jahr sagt wenig ueber heute: Datentraeger // verfaelschen Daten still. func TestStaleVerificationLosesValue(testInstance *testing.T) { freshFacts := buildIdealFacts() freshFacts.LastVerifiedAt = timePointer(referenceNow.Add(-24 * time.Hour)) staleFacts := buildIdealFacts() staleFacts.LastVerifiedAt = timePointer(referenceNow.Add(-200 * 24 * time.Hour)) freshInput := findInput(CalculateScore(freshFacts, referenceNow), "Integritaetspruefung") staleInput := findInput(CalculateScore(staleFacts, referenceNow), "Integritaetspruefung") if staleInput.EarnedPoints >= freshInput.EarnedPoints { testInstance.Fatalf("eine 200 Tage alte Pruefung zaehlt wie eine frische (%d gegen %d Punkte)", staleInput.EarnedPoints, freshInput.EarnedPoints) } // Sie zaehlt aber weiterhin als gemessen: Sie hat stattgefunden. if !staleInput.IsKnown { testInstance.Error("eine alte Pruefung wurde als nie durchgefuehrt gefuehrt") } } // TestUnmeasuredRestoreDurationIsNotAssumedGood prueft die gefaehrlichste // Annahme der Bewertung. // // Eine Vorgabe ohne Messung ist eine Absichtserklaerung. Sie als eingehalten zu // werten waere die bequemste und falscheste Entscheidung. func TestUnmeasuredRestoreDurationIsNotAssumedGood(testInstance *testing.T) { unmeasuredFacts := buildIdealFacts() unmeasuredFacts.MeasuredRestoreDuration = 0 durationInput := findInput(CalculateScore(unmeasuredFacts, referenceNow), "Wiederherstellungsdauer") if durationInput.IsKnown { testInstance.Error("eine nie gemessene Wiederherstellungsdauer wurde als gemessen gefuehrt") } if durationInput.EarnedPoints != 0 { testInstance.Fatalf("eine nie gemessene Wiederherstellungsdauer erhielt %d Punkte", durationInput.EarnedPoints) } if !strings.Contains(durationInput.Explanation, "Absichtserklaerung") { testInstance.Errorf("die Erklaerung benennt das Problem nicht: %q", durationInput.Explanation) } } // TestViolatedRecoveryPointIsNamed prueft die Verletzung einer Vorgabe. func TestViolatedRecoveryPointIsNamed(testInstance *testing.T) { violatedFacts := buildIdealFacts() violatedFacts.RecoveryPointObjective = 4 * time.Hour violatedFacts.CompletedAt = timePointer(referenceNow.Add(-48 * time.Hour)) recoveryInput := findInput(CalculateScore(violatedFacts, referenceNow), "Wiederherstellungspunkt") if recoveryInput.EarnedPoints != 0 { testInstance.Errorf("eine verletzte Vorgabe erhielt %d Punkte", recoveryInput.EarnedPoints) } if !strings.Contains(recoveryInput.Explanation, "VERLETZT") { testInstance.Errorf("die Verletzung wurde nicht benannt: %q", recoveryInput.Explanation) } } // TestScoreCoversAllTenInputs prueft die Vollstaendigkeit. // // SYNCOVA_IMPLEMENTATION_PLAN.md §12 nennt zehn Eingangsgroessen. func TestScoreCoversAllTenInputs(testInstance *testing.T) { fullScore := CalculateScore(buildIdealFacts(), referenceNow) if len(fullScore.Inputs) != 10 { testInstance.Fatalf("die Bewertung beruht auf %d Groessen, der Plan nennt 10", len(fullScore.Inputs)) } expectedNames := []string{ "Aktualitaet", "Integritaetspruefung", "Wiederherstellungstest", "Wiederherstellungspunkt", "Wiederherstellungsdauer", "Unveraenderlichkeit", "Zweiter Standort", "Verschluesselung", "Repository-Zustand", "Auffaelligkeiten", } for _, expectedName := range expectedNames { if findInput(fullScore, expectedName) == nil { testInstance.Errorf("die Eingangsgroesse %q fehlt", expectedName) } } } // TestRestoreTestHasHighestWeight prueft die Gewichtung. // // Der Wiederherstellungstest ist die einzige Groesse, die etwas nachweist statt // es nahezulegen. Er muss am schwersten wiegen. func TestRestoreTestHasHighestWeight(testInstance *testing.T) { fullScore := CalculateScore(buildIdealFacts(), referenceNow) restoreInput := findInput(fullScore, "Wiederherstellungstest") for _, scoreInput := range fullScore.Inputs { if scoreInput.Name == restoreInput.Name { continue } if scoreInput.Weight > restoreInput.Weight { testInstance.Errorf("die Groesse %q wiegt mit %d schwerer als der Wiederherstellungstest mit %d", scoreInput.Name, scoreInput.Weight, restoreInput.Weight) } } } // TestClassificationDescriptionsAreHonest prueft die Formulierungen. // // „Erfolgreich" muss ausdruecklich sagen, dass die Wiederherstellbarkeit // ungeprueft ist — sonst liest man es als Entwarnung. func TestClassificationDescriptionsAreHonest(testInstance *testing.T) { successfulDescription := ClassificationSuccessful.Describe() if !strings.Contains(successfulDescription, "nicht geprueft") { testInstance.Errorf("die Beschreibung von 'erfolgreich' verschweigt die fehlende Pruefung: %q", successfulDescription) } verifiedDescription := ClassificationVerified.Describe() if !strings.Contains(verifiedDescription, "nicht erprobt") { testInstance.Errorf("die Beschreibung von 'geprueft' verschweigt den fehlenden Test: %q", verifiedDescription) } recoverableDescription := ClassificationRecoverable.Describe() if !strings.Contains(recoverableDescription, "nachweislich") { testInstance.Errorf("die Beschreibung von 'wiederherstellbar' ist zu schwach: %q", recoverableDescription) } } // TestCorruptedBackupScoresZero prueft die Deckelung eines beschaedigten Backups. // // Der Fall stammt aus dem Nachweis der Phase: Ein Backup mit einem einzigen // beschaedigten Block kam auf 70 %, weil Aktualitaet, Verschluesselung und ein // frueherer Wiederherstellungstest weiterhin zaehlten. Diese Zahl liest sich wie // „weitgehend in Ordnung" — und genau das darf sie nicht. func TestCorruptedBackupScoresZero(testInstance *testing.T) { referenceTime := time.Now() completedAt := referenceTime.Add(-1 * time.Hour) verifiedAt := referenceTime.Add(-2 * time.Hour) restoreTestedAt := referenceTime.Add(-3 * time.Hour) corruptedFacts := BackupFacts{ RunSucceeded: true, CompletedAt: &completedAt, LastVerifiedAt: &verifiedAt, LastRestoreTestAt: &restoreTestedAt, VerificationFoundCorruption: true, IsEncrypted: true, IsImmutable: true, RepositoryHealthy: true, MeasuredRestoreDuration: time.Minute, RecoveryTimeObjective: time.Hour, RecoveryPointObjective: 24 * time.Hour, } calculatedScore := CalculateScore(corruptedFacts, referenceTime) if calculatedScore.Percentage != 0 { testInstance.Errorf("Bewertung war %d %%, erwartet wurden 0 %%", calculatedScore.Percentage) } if calculatedScore.IsTrustworthy() { testInstance.Error("ein beschaedigtes Backup wurde als vertrauenswuerdig gemeldet") } // Der fruehere Wiederherstellungstest bezieht sich auf einen Zustand, den es // nicht mehr gibt. for _, scoreInput := range calculatedScore.Inputs { if scoreInput.Name == "Wiederherstellungstest" && scoreInput.EarnedPoints != 0 { testInstance.Errorf("ein frueherer Wiederherstellungstest zaehlte trotz Befund %d Punkte", scoreInput.EarnedPoints) } } } // TestFailedRunScoresZero prueft die Deckelung eines gescheiterten Laufs. func TestFailedRunScoresZero(testInstance *testing.T) { referenceTime := time.Now() completedAt := referenceTime.Add(-30 * time.Minute) failedFacts := BackupFacts{ RunSucceeded: false, CompletedAt: &completedAt, IsEncrypted: true, IsImmutable: true, RepositoryHealthy: true, } calculatedScore := CalculateScore(failedFacts, referenceTime) if calculatedScore.Percentage != 0 { testInstance.Errorf("Bewertung war %d %%, erwartet wurden 0 %%", calculatedScore.Percentage) } if calculatedScore.IsTrustworthy() { testInstance.Error("ein gescheiterter Lauf wurde als vertrauenswuerdig gemeldet") } }