package recovery import ( "bytes" "context" "io" "log/slog" "os" "path/filepath" "strings" "testing" "github.com/syncova/syncova/packages/backupengine" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/repository" ) // discardLogger unterdrueckt die Protokollausgabe der Tests. func discardLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(io.Discard, nil)) } // buildTestSecretStore erzeugt einen Schluesselspeicher. func buildTestSecretStore(testInstance *testing.T) crypto.SecretStore { testInstance.Helper() builtStore, storeError := crypto.NewLocalSecretStore( map[string][]byte{"v1": bytes.Repeat([]byte{0x51}, 32)}, "v1") if storeError != nil { testInstance.Fatalf("der Schluesselspeicher liess sich nicht bauen: %v", storeError) } return builtStore } // backupFixture ist ein vorbereitetes Repository mit einem Backup. type backupFixture struct { // RepositoryPath ist der Pfad des Repositorys. RepositoryPath string // Repository ist das geoeffnete Repository. Repository *repository.LocalRepository // BackupID ist die Kennung des angelegten Backups. BackupID string // SourceData sind die gesicherten Inhalte je Pfad. SourceData map[string][]byte } // buildBackupFixture legt ein Repository mit einem verschluesselten Backup an. func buildBackupFixture(testInstance *testing.T, encrypted bool) *backupFixture { testInstance.Helper() repositoryPath := filepath.Join(testInstance.TempDir(), "repository") createdRepository, createError := repository.Create(context.Background(), repositoryPath, repository.CreateOptions{Name: "Pruefung", CreatedByVersion: "test"}, discardLogger()) if createError != nil { testInstance.Fatalf("das Repository liess sich nicht anlegen: %v", createError) } var secretStore crypto.SecretStore if encrypted { secretStore = buildTestSecretStore(testInstance) } engine := backupengine.NewEngine(createdRepository, secretStore, discardLogger()) sourceData := map[string][]byte{ "dokumente/bericht.txt": []byte(strings.Repeat("Inhalt eines Berichts. ", 500)), "dokumente/notiz.txt": []byte("Eine kurze Notiz."), "bilder/foto.bin": buildIncompressibleData(64 << 10), } backupSources := []backupengine.BackupSource{ {Path: "dokumente", EntryType: "directory", Mode: "0755"}, {Path: "bilder", EntryType: "directory", Mode: "0755"}, } for entryPath, entryContent := range sourceData { backupSources = append(backupSources, backupengine.BackupSource{ Path: entryPath, EntryType: "file", Mode: "0644", Reader: bytes.NewReader(entryContent), }) } if _, backupError := engine.Backup(context.Background(), backupengine.BackupOptions{ BackupID: "pruef-backup", BackupType: repository.BackupTypeFull, Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "/test"}, CompressionLevel: backupengine.CompressionOff, EncryptionEnabled: encrypted, }, backupSources); backupError != nil { testInstance.Fatalf("das Backup liess sich nicht anlegen: %v", backupError) } testInstance.Cleanup(func() { _ = createdRepository.Close() }) return &backupFixture{ RepositoryPath: repositoryPath, Repository: createdRepository, BackupID: "pruef-backup", SourceData: sourceData, } } // buildIncompressibleData erzeugt reproduzierbare, kaum komprimierbare Daten. func buildIncompressibleData(byteCount int) []byte { generatedData := make([]byte, byteCount) var generatorState uint64 = 0x2545F4914F6CDD1D for byteIndex := range generatedData { generatorState ^= generatorState << 13 generatorState ^= generatorState >> 7 generatorState ^= generatorState << 17 generatedData[byteIndex] = byte(generatorState) } return generatedData } // findFinding sucht einen Befund anhand seines Codes. func findFinding(report *ValidationReport, findingCode string) *ValidationFinding { for findingIndex := range report.Findings { if report.Findings[findingIndex].Code == findingCode { return &report.Findings[findingIndex] } } return nil } // TestValidationAcceptsIntactBackup prueft den Normalfall. func TestValidationAcceptsIntactBackup(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) validator := NewValidator(fixture.Repository) report, validateError := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: filepath.Join(testInstance.TempDir(), "ziel"), DeepChunkCheck: true, }) if validateError != nil { testInstance.Fatalf("die Pruefung schlug fehl: %v", validateError) } if !report.CanProceed() { testInstance.Fatalf("ein unversehrtes Backup wurde abgelehnt: %v", report.BlockingFindings()) } if report.FileCount != 3 { testInstance.Errorf("es wurden %d Dateien gezaehlt, erwartet waren 3", report.FileCount) } if report.MissingChunkCount != 0 { testInstance.Errorf("es fehlen angeblich %d Bloecke", report.MissingChunkCount) } if report.UniqueChunkCount == 0 { testInstance.Error("es wurden keine Bloecke geprueft; die Pruefung ist ohne Aussage") } } // TestValidationDetectsMissingChunk ist der wichtigste Test dieser Datei. // // Ein Manifest allein belegt nur, dass jemand einmal etwas gesichert hat — // nicht, dass die Daten noch da sind. Ein versehentlich aufgeraeumtes // Verzeichnis oder ein unvollstaendig kopiertes Repository muss **vor** dem // Ernstfall auffallen. func TestValidationDetectsMissingChunk(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) backupManifest, readError := fixture.Repository.ReadManifest(context.Background(), fixture.BackupID) if readError != nil { testInstance.Fatalf("das Manifest liess sich nicht lesen: %v", readError) } // Ein Block wird aus der Ablage entfernt. var removedIdentifier string for chunkIdentifier := range backupManifest.UniqueChunkIdentifiers() { removedIdentifier = chunkIdentifier break } chunkPath := filepath.Join(fixture.RepositoryPath, "chunks", removedIdentifier[0:2], removedIdentifier[2:4], removedIdentifier) if removeError := os.Remove(chunkPath); removeError != nil { testInstance.Fatalf("der Block liess sich nicht entfernen: %v", removeError) } validator := NewValidator(fixture.Repository) report, validateError := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: filepath.Join(testInstance.TempDir(), "ziel"), DeepChunkCheck: true, }) if validateError != nil { testInstance.Fatalf("die Pruefung schlug fehl: %v", validateError) } if report.CanProceed() { testInstance.Fatal("ein Backup mit fehlendem Block wurde als wiederherstellbar gemeldet") } missingFinding := findFinding(report, "CHUNKS_MISSING") if missingFinding == nil { testInstance.Fatalf("der fehlende Block wurde nicht benannt: %+v", report.Findings) } // Die Meldung muss das betroffene Objekt nennen; eine blosse Zahl hilft // niemandem weiter. if missingFinding.Detail == "" { testInstance.Error("der Befund nennt kein betroffenes Objekt") } if report.MissingChunkCount != 1 { testInstance.Errorf("es wurden %d fehlende Bloecke gemeldet, erwartet war 1", report.MissingChunkCount) } if !strings.Contains(report.Summary(), "NICHT WIEDERHERSTELLBAR") { testInstance.Errorf("die Zusammenfassung beschoenigt den Befund: %q", report.Summary()) } } // TestValidationWithoutDeepCheckSaysSo prueft die Ehrlichkeit der flachen Pruefung. // // Ohne Blockpruefung ist nicht festgestellt, ob die Daten da sind. Das muss im // Bericht stehen, sonst haelt man einen halben Nachweis fuer einen ganzen. func TestValidationWithoutDeepCheckSaysSo(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) validator := NewValidator(fixture.Repository) report, _ := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: filepath.Join(testInstance.TempDir(), "ziel"), DeepChunkCheck: false, }) skippedFinding := findFinding(report, "CHUNK_CHECK_SKIPPED") if skippedFinding == nil { testInstance.Fatal("die uebersprungene Blockpruefung wurde nicht ausgewiesen") } if skippedFinding.Severity != SeverityInformation { testInstance.Errorf("der Hinweis wurde als %q eingestuft", skippedFinding.Severity) } // Sie verhindert nichts — sie sagt nur, was nicht geprueft wurde. if !report.CanProceed() { testInstance.Error("die flache Pruefung verhinderte die Wiederherstellung") } } // TestValidationRefusesNonEmptyTargetWithoutConsent ist der Schutztest. // // Die vorhandenen Daten koennten genau die sein, die man eigentlich retten // will. func TestValidationRefusesNonEmptyTargetWithoutConsent(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) occupiedTarget := testInstance.TempDir() if writeError := os.WriteFile(filepath.Join(occupiedTarget, "vorhanden.txt"), []byte("wichtig"), 0o644); writeError != nil { testInstance.Fatalf("die Datei liess sich nicht anlegen: %v", writeError) } validator := NewValidator(fixture.Repository) report, _ := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: occupiedTarget, }) if report.CanProceed() { testInstance.Fatal("ein nicht leeres Ziel wurde ohne Zustimmung angenommen") } if findFinding(report, "TARGET_NOT_EMPTY") == nil { testInstance.Errorf("das volle Zielverzeichnis wurde nicht benannt: %+v", report.Findings) } // Mit ausdruecklicher Zustimmung geht es — aber mit Warnung. consentedReport, _ := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: occupiedTarget, OverwriteExisting: true, }) if !consentedReport.CanProceed() { testInstance.Fatalf("mit Zustimmung wurde weiterhin abgelehnt: %v", consentedReport.BlockingFindings()) } overwriteFinding := findFinding(consentedReport, "TARGET_WILL_BE_OVERWRITTEN") if overwriteFinding == nil || overwriteFinding.Severity != SeverityWarning { testInstance.Error("das Ueberschreiben wurde nicht als Warnung ausgewiesen") } } // TestValidationDetectsUnknownBackup prueft ein fehlendes Manifest. func TestValidationDetectsUnknownBackup(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) validator := NewValidator(fixture.Repository) report, validateError := validator.Validate(context.Background(), ValidationRequest{ BackupID: "gibt-es-nicht", TargetPath: filepath.Join(testInstance.TempDir(), "ziel"), }) // Ein unbekanntes Backup ist ein Befund, kein Programmfehler: Die Pruefung // soll berichten, nicht abbrechen. if validateError != nil { testInstance.Fatalf("die Pruefung brach ab, statt zu berichten: %v", validateError) } if report.CanProceed() { testInstance.Fatal("ein unbekanntes Backup wurde als wiederherstellbar gemeldet") } if findFinding(report, "MANIFEST_UNREADABLE") == nil { testInstance.Errorf("das fehlende Manifest wurde nicht benannt: %+v", report.Findings) } } // TestValidationRestrictsToSubtree prueft die Teilbaumauswahl. // // Der haeufigste Fall im Betrieb ist nicht die vollstaendige Wiederherstellung, // sondern eine einzelne versehentlich geloeschte Datei. func TestValidationRestrictsToSubtree(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) validator := NewValidator(fixture.Repository) report, _ := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: filepath.Join(testInstance.TempDir(), "ziel"), PathPrefix: "dokumente", DeepChunkCheck: true, }) if !report.CanProceed() { testInstance.Fatalf("der Teilbaum wurde abgelehnt: %v", report.BlockingFindings()) } // Zwei Dateien liegen unter dokumente/, die dritte unter bilder/. if report.FileCount != 2 { testInstance.Errorf("es wurden %d Dateien gezaehlt, erwartet waren 2", report.FileCount) } } // TestValidationRespectsDirectoryBoundary prueft die Praefixgrenze. // // "dokumente" darf nicht auch "dokumentation" treffen — sonst kaeme bei einer // gezielten Wiederherstellung mehr zurueck als verlangt. func TestValidationRespectsDirectoryBoundary(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) validator := NewValidator(fixture.Repository) report, _ := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: filepath.Join(testInstance.TempDir(), "ziel"), PathPrefix: "dokument", }) if report.CanProceed() { testInstance.Fatal("das Praefix 'dokument' traf Objekte unter 'dokumente'") } if findFinding(report, "NO_MATCHING_ENTRIES") == nil { testInstance.Errorf("der leere Teilbaum wurde nicht benannt: %+v", report.Findings) } } // TestValidationReportsUnencryptedBackup prueft den Hinweis auf fehlende // Verschluesselung. func TestValidationReportsUnencryptedBackup(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, false) validator := NewValidator(fixture.Repository) report, _ := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: filepath.Join(testInstance.TempDir(), "ziel"), }) unencryptedFinding := findFinding(report, "BACKUP_UNENCRYPTED") if unencryptedFinding == nil { testInstance.Fatalf("das unverschluesselte Backup wurde nicht benannt: %+v", report.Findings) } // Es ist ein Hinweis, kein Hindernis: Wiederherstellen laesst es sich. if unencryptedFinding.Severity != SeverityWarning { testInstance.Errorf("der Hinweis wurde als %q eingestuft", unencryptedFinding.Severity) } if !report.CanProceed() { testInstance.Error("ein unverschluesseltes Backup wurde fuer nicht wiederherstellbar erklaert") } } // TestValidationDetectsMissingParentDirectory prueft ein unerreichbares Ziel. func TestValidationDetectsMissingParentDirectory(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) validator := NewValidator(fixture.Repository) report, _ := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: "/gibt-es-nicht/und-hier-auch-nicht/ziel", }) if report.CanProceed() { testInstance.Fatal("ein Ziel ohne vorhandenes Elternverzeichnis wurde angenommen") } if findFinding(report, "TARGET_PARENT_MISSING") == nil { testInstance.Errorf("das fehlende Elternverzeichnis wurde nicht benannt: %+v", report.Findings) } } // TestValidationWritesNothing ist der Grundtest der Vorabpruefung. // // Sie soll gefahrlos jederzeit laufen koennen — auch als regelmaessiger // Nachweis, dass die Backups weiterhin wiederherstellbar sind. func TestValidationWritesNothing(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) validator := NewValidator(fixture.Repository) targetPath := testInstance.TempDir() if _, validateError := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: targetPath, DeepChunkCheck: true, }); validateError != nil { testInstance.Fatalf("die Pruefung schlug fehl: %v", validateError) } targetEntries, readError := os.ReadDir(targetPath) if readError != nil { testInstance.Fatalf("das Ziel liess sich nicht lesen: %v", readError) } if len(targetEntries) != 0 { testInstance.Fatalf("die Pruefung hinterliess %d Objekte im Ziel", len(targetEntries)) } } // TestValidationReportsFreeSpace prueft die Platzangabe. func TestValidationReportsFreeSpace(testInstance *testing.T) { fixture := buildBackupFixture(testInstance, true) validator := NewValidator(fixture.Repository) report, _ := validator.Validate(context.Background(), ValidationRequest{ BackupID: fixture.BackupID, TargetPath: testInstance.TempDir(), }) if report.AvailableTargetBytes <= 0 { testInstance.Errorf("der freie Platz wurde als %d gemeldet", report.AvailableTargetBytes) } }