syncova-backup/packages/verification/assurance_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

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