Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
433 lines
16 KiB
Go
433 lines
16 KiB
Go
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")
|
|
}
|
|
}
|