syncova-backup/packages/verification/verifier_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

499 lines
17 KiB
Go

package verification
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{0x77}, 32)}, "v1")
if storeError != nil {
testInstance.Fatalf("der Schluesselspeicher liess sich nicht bauen: %v", storeError)
}
return builtStore
}
// verificationFixture ist ein Repository mit einem Backup.
type verificationFixture struct {
// RepositoryPath ist der Pfad des Repositorys.
RepositoryPath string
// Repository ist das geoeffnete Repository.
Repository *repository.LocalRepository
// SecretStore entschluesselt die Datenschluessel.
SecretStore crypto.SecretStore
// BackupID ist die Kennung des Backups.
BackupID string
// SourceContents sind die gesicherten Inhalte je Pfad.
SourceContents map[string][]byte
}
// buildFixture legt ein Repository mit einem verschluesselten Backup an.
func buildFixture(testInstance *testing.T) *verificationFixture {
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)
}
testInstance.Cleanup(func() { _ = createdRepository.Close() })
secretStore := buildTestSecretStore(testInstance)
engine := backupengine.NewEngine(createdRepository, secretStore, discardLogger())
sourceContents := map[string][]byte{
"dokumente/bericht.txt": []byte(strings.Repeat("Ein Bericht. ", 400)),
"dokumente/notiz.txt": []byte("Kurze Notiz."),
"bilder/foto.bin": buildIncompressibleData(48 << 10),
}
backupSources := []backupengine.BackupSource{
{Path: "bilder", EntryType: "directory", Mode: "0755"},
{Path: "dokumente", EntryType: "directory", Mode: "0755"},
}
for entryPath, entryContent := range sourceContents {
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-1",
BackupType: repository.BackupTypeFull,
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "/test"},
CompressionLevel: backupengine.CompressionOff,
EncryptionEnabled: true,
}, backupSources); backupError != nil {
testInstance.Fatalf("das Backup liess sich nicht anlegen: %v", backupError)
}
return &verificationFixture{
RepositoryPath: repositoryPath,
Repository: createdRepository,
SecretStore: secretStore,
BackupID: "pruef-1",
SourceContents: sourceContents,
}
}
// buildIncompressibleData erzeugt reproduzierbare, kaum komprimierbare Daten.
func buildIncompressibleData(byteCount int) []byte {
generatedData := make([]byte, byteCount)
var generatorState uint64 = 0x9E3779B97F4A7C15
for byteIndex := range generatedData {
generatorState ^= generatorState << 13
generatorState ^= generatorState >> 7
generatorState ^= generatorState << 17
generatedData[byteIndex] = byte(generatorState)
}
return generatedData
}
// chunkPathOf bildet den Ablagepfad eines Blocks.
func chunkPathOf(repositoryPath string, chunkIdentifier string) string {
return filepath.Join(repositoryPath, "chunks",
chunkIdentifier[0:2], chunkIdentifier[2:4], chunkIdentifier)
}
// anyChunkIdentifier liefert eine beliebige Blockkennung des Backups.
func anyChunkIdentifier(testInstance *testing.T, fixture *verificationFixture) string {
testInstance.Helper()
backupManifest, readError := fixture.Repository.ReadManifest(context.Background(), fixture.BackupID)
if readError != nil {
testInstance.Fatalf("das Manifest liess sich nicht lesen: %v", readError)
}
for chunkIdentifier := range backupManifest.UniqueChunkIdentifiers() {
return chunkIdentifier
}
testInstance.Fatal("das Backup enthaelt keine Bloecke")
return ""
}
// findFinding sucht einen Befund anhand seines Codes.
func findFinding(report *Report, findingCode string) *Finding {
for findingIndex := range report.Findings {
if report.Findings[findingIndex].Code == findingCode {
return &report.Findings[findingIndex]
}
}
return nil
}
// TestVerifyIntactBackup prueft den Normalfall.
func TestVerifyIntactBackup(testInstance *testing.T) {
fixture := buildFixture(testInstance)
verifier := NewVerifier(fixture.Repository)
report, verifyError := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkIntegrity)
if verifyError != nil {
testInstance.Fatalf("die Pruefung schlug fehl: %v", verifyError)
}
if !report.IsClean() {
testInstance.Fatalf("ein unversehrtes Backup wurde beanstandet: %+v", report.Findings)
}
if report.ChunksChecked == 0 {
testInstance.Error("es wurden keine Bloecke geprueft; die Pruefung ist ohne Aussage")
}
if report.BytesRead == 0 {
testInstance.Error("es wurden keine Bytes gelesen; die Inhaltspruefung fand nicht statt")
}
}
// TestVerifyDetectsCorruptedChunk ist der wichtigste Test dieser Datei.
//
// Ein Datentraeger kann Daten still verfaelschen. Nur eine Pruefung, die den
// Inhalt liest und neu hasht, deckt das auf.
func TestVerifyDetectsCorruptedChunk(testInstance *testing.T) {
fixture := buildFixture(testInstance)
chunkIdentifier := anyChunkIdentifier(testInstance, fixture)
chunkPath := chunkPathOf(fixture.RepositoryPath, chunkIdentifier)
originalData, readError := os.ReadFile(chunkPath)
if readError != nil {
testInstance.Fatalf("der Block liess sich nicht lesen: %v", readError)
}
// Ein einziges Bit kippen — die leiseste denkbare Beschaedigung.
corruptedData := make([]byte, len(originalData))
copy(corruptedData, originalData)
corruptedData[len(corruptedData)/2] ^= 0x01
if writeError := os.WriteFile(chunkPath, corruptedData, 0o600); writeError != nil {
testInstance.Fatalf("der Block liess sich nicht veraendern: %v", writeError)
}
verifier := NewVerifier(fixture.Repository)
// Die Existenzpruefung merkt nichts — der Block ist ja da.
presenceReport, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkPresence)
if !presenceReport.IsClean() {
testInstance.Error("die Existenzpruefung meldete einen Fehler, obwohl der Block vorhanden ist")
}
// Erst die Inhaltspruefung deckt es auf.
integrityReport, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkIntegrity)
if !integrityReport.HasCorruption() {
testInstance.Fatal("ein gekipptes Bit wurde nicht erkannt")
}
corruptionFinding := findFinding(integrityReport, "CHUNKS_CORRUPTED")
if corruptionFinding == nil {
testInstance.Fatalf("die Beschaedigung wurde nicht benannt: %+v", integrityReport.Findings)
}
// Der Befund muss das betroffene Objekt nennen.
if corruptionFinding.Detail == "" {
testInstance.Error("der Befund nennt kein betroffenes Objekt")
}
if !strings.Contains(integrityReport.Summary(), "BESCHAEDIGT") {
testInstance.Errorf("die Zusammenfassung beschoenigt den Befund: %q", integrityReport.Summary())
}
}
// TestVerifyDetectsMissingChunk prueft den fehlenden Block.
func TestVerifyDetectsMissingChunk(testInstance *testing.T) {
fixture := buildFixture(testInstance)
chunkIdentifier := anyChunkIdentifier(testInstance, fixture)
if removeError := os.Remove(chunkPathOf(fixture.RepositoryPath, chunkIdentifier)); removeError != nil {
testInstance.Fatalf("der Block liess sich nicht entfernen: %v", removeError)
}
verifier := NewVerifier(fixture.Repository)
report, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkPresence)
if report.IsClean() {
testInstance.Fatal("ein fehlender Block wurde nicht erkannt")
}
// Fehlend ist nicht beschaedigt: Der Block koennte anderswo noch existieren.
if report.HasCorruption() {
testInstance.Error("ein fehlender Block wurde als Beschaedigung gemeldet")
}
if findFinding(report, "CHUNKS_MISSING") == nil {
testInstance.Errorf("der fehlende Block wurde nicht benannt: %+v", report.Findings)
}
}
// TestManifestVerificationIsHonestAboutItsLimits prueft die schwache Aussage.
//
// Die Manifestpruefung stellt fest, dass das Backup abgeschlossen wurde — nicht,
// ob die Daten noch da sind. Das muss in der Zusammenfassung stehen.
func TestManifestVerificationIsHonestAboutItsLimits(testInstance *testing.T) {
fixture := buildFixture(testInstance)
// Alle Bloecke entfernen: Das Manifest bleibt stimmig, die Daten sind weg.
backupManifest, _ := fixture.Repository.ReadManifest(context.Background(), fixture.BackupID)
for chunkIdentifier := range backupManifest.UniqueChunkIdentifiers() {
_ = os.Remove(chunkPathOf(fixture.RepositoryPath, chunkIdentifier))
}
verifier := NewVerifier(fixture.Repository)
manifestReport, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeManifest)
// Die Manifestpruefung findet nichts — sie hat die Bloecke nie angesehen.
if !manifestReport.IsClean() {
testInstance.Error("die Manifestpruefung beanstandete etwas, das sie nicht geprueft hat")
}
// Sie sagt aber, dass sie nichts ueber die Daten weiss.
if !strings.Contains(manifestReport.Summary(), "nicht geprueft") {
testInstance.Fatalf("die Manifestpruefung verschweigt ihre Grenze: %q", manifestReport.Summary())
}
// Und die Blockpruefung deckt den Verlust sofort auf.
presenceReport, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkPresence)
if presenceReport.IsClean() {
testInstance.Error("die Blockpruefung uebersah ein vollstaendig geleertes Repository")
}
}
// TestVerifyChainFindsBrokenParent prueft die Kettenpruefung.
func TestVerifyChainFindsBrokenParent(testInstance *testing.T) {
fixture := buildFixture(testInstance)
engine := backupengine.NewEngine(fixture.Repository, fixture.SecretStore, discardLogger())
// Eine Zusatzsicherung auf das vorhandene Backup.
if _, backupError := engine.Backup(context.Background(), backupengine.BackupOptions{
BackupID: "pruef-2",
BackupType: repository.BackupTypeIncremental,
ParentBackupID: fixture.BackupID,
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "/test"},
CompressionLevel: backupengine.CompressionOff,
EncryptionEnabled: true,
}, []backupengine.BackupSource{
{Path: "neu.txt", EntryType: "file", Mode: "0644", Reader: bytes.NewReader([]byte("neu"))},
}); backupError != nil {
testInstance.Fatalf("die Zusatzsicherung liess sich nicht anlegen: %v", backupError)
}
verifier := NewVerifier(fixture.Repository)
intactReport, _ := verifier.VerifyChain(context.Background(), "pruef-2")
if !intactReport.IsClean() {
testInstance.Fatalf("eine vollstaendige Kette wurde beanstandet: %+v", intactReport.Findings)
}
if intactReport.ChainLength != 2 {
testInstance.Errorf("die Kette wurde mit %d Backups gezaehlt, erwartet waren 2", intactReport.ChainLength)
}
// Das Elternbackup verschwindet.
if removeError := os.Remove(filepath.Join(fixture.RepositoryPath, "manifests",
fixture.BackupID+".manifest.json")); removeError != nil {
testInstance.Fatalf("das Elternmanifest liess sich nicht entfernen: %v", removeError)
}
brokenReport, _ := verifier.VerifyChain(context.Background(), "pruef-2")
if brokenReport.IsClean() {
testInstance.Fatal("eine unterbrochene Kette wurde nicht erkannt")
}
if findFinding(brokenReport, "CHAIN_BROKEN") == nil {
testInstance.Errorf("die Unterbrechung wurde nicht benannt: %+v", brokenReport.Findings)
}
}
// TestRestoreTestSucceedsOnIntactBackup prueft den Nachweis.
func TestRestoreTestSucceedsOnIntactBackup(testInstance *testing.T) {
fixture := buildFixture(testInstance)
tester := NewRestoreTester(fixture.Repository, fixture.SecretStore, discardLogger())
workingDirectory := testInstance.TempDir()
report, testError := tester.RunTest(context.Background(), RestoreTestOptions{
BackupID: fixture.BackupID,
WorkingDirectory: workingDirectory,
})
if testError != nil {
testInstance.Fatalf("der Test schlug fehl: %v", testError)
}
if !report.Succeeded() {
testInstance.Fatalf("ein unversehrtes Backup bestand den Test nicht: %+v", report.Findings)
}
if report.FilesCompared != len(fixture.SourceContents) {
testInstance.Errorf("es wurden %d Dateien verglichen, gesichert waren %d",
report.FilesCompared, len(fixture.SourceContents))
}
if report.FilesMismatched != 0 {
testInstance.Errorf("%d Dateien wichen ab", report.FilesMismatched)
}
}
// TestRestoreTestCleansUpAfterItself prueft das Aufraeumen.
//
// Ein Test, der Platz belegt, wird bald abgeschaltet.
func TestRestoreTestCleansUpAfterItself(testInstance *testing.T) {
fixture := buildFixture(testInstance)
tester := NewRestoreTester(fixture.Repository, fixture.SecretStore, discardLogger())
workingDirectory := testInstance.TempDir()
if _, testError := tester.RunTest(context.Background(), RestoreTestOptions{
BackupID: fixture.BackupID,
WorkingDirectory: workingDirectory,
}); testError != nil {
testInstance.Fatalf("der Test schlug fehl: %v", testError)
}
remainingEntries, readError := os.ReadDir(workingDirectory)
if readError != nil {
testInstance.Fatalf("das Arbeitsverzeichnis liess sich nicht lesen: %v", readError)
}
if len(remainingEntries) != 0 {
testInstance.Fatalf("der Test hinterliess %d Objekte im Arbeitsverzeichnis", len(remainingEntries))
}
}
// TestRestoreTestDetectsCorruption ist der Nachweis, dass der Test etwas taugt.
//
// Er muss aufdecken, was die Blockpruefung erkennt — und mehr: Fehler in
// Entschluesselung, Dekompression und Zusammensetzung fallen nur hier auf.
func TestRestoreTestDetectsCorruption(testInstance *testing.T) {
fixture := buildFixture(testInstance)
chunkIdentifier := anyChunkIdentifier(testInstance, fixture)
chunkPath := chunkPathOf(fixture.RepositoryPath, chunkIdentifier)
originalData, _ := os.ReadFile(chunkPath)
corruptedData := make([]byte, len(originalData))
copy(corruptedData, originalData)
corruptedData[len(corruptedData)/2] ^= 0xFF
if writeError := os.WriteFile(chunkPath, corruptedData, 0o600); writeError != nil {
testInstance.Fatalf("der Block liess sich nicht veraendern: %v", writeError)
}
tester := NewRestoreTester(fixture.Repository, fixture.SecretStore, discardLogger())
report, testError := tester.RunTest(context.Background(), RestoreTestOptions{
BackupID: fixture.BackupID,
WorkingDirectory: testInstance.TempDir(),
})
if testError != nil {
testInstance.Fatalf("der Test brach ab, statt zu berichten: %v", testError)
}
if report.Succeeded() {
testInstance.Fatal("ein beschaedigtes Backup bestand den Wiederherstellungstest")
}
}
// TestPartialRestoreTestSaysSo prueft die Ehrlichkeit des Teiltests.
//
// Ein Teiltest belegt nicht die Wiederherstellbarkeit des gesamten Backups.
func TestPartialRestoreTestSaysSo(testInstance *testing.T) {
fixture := buildFixture(testInstance)
tester := NewRestoreTester(fixture.Repository, fixture.SecretStore, discardLogger())
report, testError := tester.RunTest(context.Background(), RestoreTestOptions{
BackupID: fixture.BackupID,
PathPrefix: "dokumente",
WorkingDirectory: testInstance.TempDir(),
})
if testError != nil {
testInstance.Fatalf("der Teiltest schlug fehl: %v", testError)
}
if !report.Succeeded() {
testInstance.Fatalf("der Teiltest schlug fehl: %+v", report.Findings)
}
if !report.WasPartialTest {
testInstance.Error("der Teiltest wurde nicht als solcher gefuehrt")
}
partialFinding := findFinding(&report.Report, "PARTIAL_TEST")
if partialFinding == nil {
testInstance.Fatalf("der Teiltest wurde nicht ausgewiesen: %+v", report.Findings)
}
if !strings.Contains(partialFinding.Message, "nicht die Wiederherstellbarkeit des gesamten") {
testInstance.Errorf("der Hinweis ist zu schwach: %q", partialFinding.Message)
}
// Nur die zwei Dateien unter dokumente/ wurden verglichen.
if report.FilesCompared != 2 {
testInstance.Errorf("es wurden %d Dateien verglichen, erwartet waren 2", report.FilesCompared)
}
}
// TestVerifyUnknownBackupReportsInsteadOfFailing prueft die Fehlerbehandlung.
func TestVerifyUnknownBackupReportsInsteadOfFailing(testInstance *testing.T) {
fixture := buildFixture(testInstance)
verifier := NewVerifier(fixture.Repository)
report, verifyError := verifier.VerifyBackup(context.Background(), "gibt-es-nicht", TypeChunkIntegrity)
// Ein unbekanntes Backup ist ein Befund, kein Programmfehler.
if verifyError != nil {
testInstance.Fatalf("die Pruefung brach ab, statt zu berichten: %v", verifyError)
}
if report.IsClean() {
testInstance.Fatal("ein unbekanntes Backup wurde als einwandfrei gemeldet")
}
if findFinding(report, "MANIFEST_UNREADABLE") == nil {
testInstance.Errorf("das fehlende Manifest wurde nicht benannt: %+v", report.Findings)
}
}