syncova-backup/packages/recovery/validation_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

461 lines
16 KiB
Go

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