syncova-backup/packages/agent/roundtrip_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

617 lines
22 KiB
Go

package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
"github.com/syncova/syncova/packages/backupengine"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/repository"
)
// newRoundTripEnvironment baut Repository, Engine und Läufer für einen Test.
func newRoundTripEnvironment(testInstance *testing.T) (*BackupRunner, *RestoreRunner) {
testInstance.Helper()
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
testRepository, createError := repository.Create(context.Background(), testInstance.TempDir(),
repository.CreateOptions{Name: "Rundreise", CreatedByVersion: "test"}, testLogger)
if createError != nil {
testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError)
}
testInstance.Cleanup(func() { _ = testRepository.Close() })
secretStore, storeError := crypto.NewLocalSecretStore(
map[string][]byte{"v1": bytes.Repeat([]byte{0x33}, 32)}, "v1")
if storeError != nil {
testInstance.Fatalf("der Secret Store konnte nicht erstellt werden: %v", storeError)
}
engine := backupengine.NewEngine(testRepository, secretStore, testLogger)
return NewBackupRunner(engine, testLogger), NewRestoreRunner(engine, testLogger)
}
// buildRealisticTree legt einen Verzeichnisbaum mit gemischtem Inhalt an.
func buildRealisticTree(testInstance *testing.T) string {
testInstance.Helper()
rootPath := testInstance.TempDir()
fileContents := map[string]string{
"dokumente/bericht.txt": strings.Repeat("Ein Bericht mit wiederholtem Inhalt. ", 500),
"dokumente/notiz.txt": "Eine kurze Notiz.",
"dokumente/unterordner/tief.md": "# Überschrift\n\nInhalt in der Tiefe.",
"bilder/foto.bin": string(buildIncompressibleData(200 * 1024)),
"leer.txt": "",
}
for relativePath, fileContent := range fileContents {
fullPath := filepath.Join(rootPath, relativePath)
if directoryError := os.MkdirAll(filepath.Dir(fullPath), 0o755); directoryError != nil {
testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError)
}
if writeError := os.WriteFile(fullPath, []byte(fileContent), 0o644); writeError != nil {
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError)
}
}
// Eine Datei mit abweichenden Rechten belegt, dass diese erhalten bleiben.
if chmodError := os.Chmod(filepath.Join(rootPath, "dokumente/notiz.txt"), 0o600); chmodError != nil {
testInstance.Fatalf("die Rechte konnten nicht gesetzt werden: %v", chmodError)
}
// Ein leeres Verzeichnis geht ohne eigenen Manifesteintrag verloren.
if directoryError := os.MkdirAll(filepath.Join(rootPath, "leerer-ordner"), 0o755); directoryError != nil {
testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError)
}
if runtime.GOOS != "windows" {
if linkError := os.Symlink("../dokumente/notiz.txt",
filepath.Join(rootPath, "bilder", "verweis.txt")); linkError != nil {
testInstance.Fatalf("der Verweis konnte nicht angelegt werden: %v", linkError)
}
}
return rootPath
}
// 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
}
// treeFingerprint bildet eine Prüfsumme über Struktur, Inhalt und Rechte.
//
// Sie ist der eigentliche Nachweis: nur wenn zwei Bäume denselben Fingerabdruck
// ergeben, wurde tatsächlich alles zurückgewonnen.
func treeFingerprint(testInstance *testing.T, rootPath string, includePermissions bool) string {
testInstance.Helper()
var collectedLines []string
walkError := filepath.Walk(rootPath, func(currentPath string, entryInformation os.FileInfo, walkError error) error {
if walkError != nil {
return walkError
}
relativePath, relativeError := filepath.Rel(rootPath, currentPath)
if relativeError != nil {
return relativeError
}
if relativePath == "." {
return nil
}
relativePath = filepath.ToSlash(relativePath)
// Lstat statt Stat: ein symbolischer Verweis soll als solcher erscheinen,
// nicht als sein Ziel.
linkInformation, statError := os.Lstat(currentPath)
if statError != nil {
return statError
}
permissionPart := ""
if includePermissions {
permissionPart = entryInformation.Mode().Perm().String()
}
switch {
case linkInformation.Mode()&os.ModeSymlink != 0:
linkTarget, _ := os.Readlink(currentPath)
collectedLines = append(collectedLines, "symlink "+relativePath+" -> "+linkTarget)
case entryInformation.IsDir():
collectedLines = append(collectedLines, "dir "+relativePath+" "+permissionPart)
default:
fileContent, readError := os.ReadFile(currentPath)
if readError != nil {
return readError
}
contentDigest := sha256.Sum256(fileContent)
collectedLines = append(collectedLines,
"file "+relativePath+" "+permissionPart+" "+hex.EncodeToString(contentDigest[:]))
}
return nil
})
if walkError != nil {
testInstance.Fatalf("der Baum konnte nicht gelesen werden: %v", walkError)
}
sort.Strings(collectedLines)
overallDigest := sha256.Sum256([]byte(strings.Join(collectedLines, "\n")))
return hex.EncodeToString(overallDigest[:])
}
// ---------------------------------------------------------------------------
// Die vertikale Scheibe
// ---------------------------------------------------------------------------
func TestBackupAndRestoreWholeTree(testInstance *testing.T) {
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
originalFingerprint := treeFingerprint(testInstance, sourcePath, true)
backupResult, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "rundreise",
SourcePath: sourcePath,
SourceName: "Testquelle",
CompressionLevel: backupengine.CompressionBalanced,
EncryptionEnabled: true,
CreatedByVersion: "test",
})
if backupError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
}
// Ohne Probleme muss der Lauf als voller Erfolg gelten.
if backupResult.IsPartialFailure() {
testInstance.Fatalf("die Sicherung meldete Probleme: %+v", backupResult.Problems)
}
if backupResult.FilesBackedUp != 5 {
testInstance.Errorf("es sollen 5 Dateien gesichert sein, waren %d", backupResult.FilesBackedUp)
}
// Das leere Verzeichnis muss mitgesichert sein, sonst ginge es verloren.
if backupResult.DirectoriesRecorded < 4 {
testInstance.Errorf("es sollen mindestens 4 Verzeichnisse vermerkt sein, waren %d",
backupResult.DirectoriesRecorded)
}
// Die Wiederherstellung erfolgt an einen völlig anderen Ort.
targetPath := filepath.Join(testInstance.TempDir(), "wiederhergestellt")
restoreResult, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{
BackupID: "rundreise",
TargetPath: targetPath,
RestorePermissions: true,
})
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if restoreResult.FilesRestored != backupResult.FilesBackedUp {
testInstance.Errorf("es sollen %d Dateien zurückkommen, waren %d",
backupResult.FilesBackedUp, restoreResult.FilesRestored)
}
// Das entscheidende Kriterium: Struktur, Inhalt und Rechte müssen
// vollständig übereinstimmen.
restoredFingerprint := treeFingerprint(testInstance, targetPath, true)
if restoredFingerprint != originalFingerprint {
testInstance.Fatalf("der wiederhergestellte Baum weicht ab.\n Quelle: %s\n Ziel: %s",
originalFingerprint, restoredFingerprint)
}
}
func TestRestoreAfterSourceDeletion(testInstance *testing.T) {
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
originalFingerprint := treeFingerprint(testInstance, sourcePath, true)
if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "vor-verlust",
SourcePath: sourcePath,
CompressionLevel: backupengine.CompressionBalanced,
EncryptionEnabled: true,
}); backupError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
}
// Der Ernstfall: die Quelle ist weg.
if removeError := os.RemoveAll(sourcePath); removeError != nil {
testInstance.Fatalf("die Quelle konnte nicht gelöscht werden: %v", removeError)
}
if _, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{
BackupID: "vor-verlust",
TargetPath: sourcePath,
RestorePermissions: true,
}); restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
// Der wiederhergestellte Baum muss dem verlorenen gleichen.
if treeFingerprint(testInstance, sourcePath, true) != originalFingerprint {
testInstance.Fatal("der wiederhergestellte Baum weicht vom verlorenen ab")
}
}
func TestRestoreOfSubtreeOnly(testInstance *testing.T) {
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "teilbaum",
SourcePath: sourcePath,
CompressionLevel: backupengine.CompressionBalanced,
EncryptionEnabled: true,
}); backupError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
}
targetPath := filepath.Join(testInstance.TempDir(), "nur-dokumente")
restoreResult, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{
BackupID: "teilbaum",
TargetPath: targetPath,
PathPrefix: "dokumente",
})
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
// Nur der gewählte Teilbaum darf zurückkommen.
if restoreResult.FilesRestored != 3 {
testInstance.Errorf("es sollen 3 Dateien zurückkommen, waren %d", restoreResult.FilesRestored)
}
if _, statError := os.Stat(filepath.Join(targetPath, "bilder")); statError == nil {
testInstance.Error("ein nicht gewählter Teilbaum wurde wiederhergestellt")
}
}
// ---------------------------------------------------------------------------
// Schutz vor Datenverlust
// ---------------------------------------------------------------------------
func TestRestoreRefusesNonEmptyTargetByDefault(testInstance *testing.T) {
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "schutz",
SourcePath: sourcePath,
}); backupError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
}
// Ein Zielverzeichnis mit vorhandenen Daten.
targetPath := testInstance.TempDir()
wichtigeDatei := filepath.Join(targetPath, "wichtig.txt")
if writeError := os.WriteFile(wichtigeDatei, []byte("Produktionsdaten"), 0o644); writeError != nil {
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError)
}
// Ohne ausdrückliche Erlaubnis darf nichts überschrieben werden.
_, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{
BackupID: "schutz",
TargetPath: targetPath,
})
if !errors.Is(restoreError, ErrTargetNotEmpty) {
testInstance.Fatalf("ein volles Zielverzeichnis muss abgelehnt werden, war: %v", restoreError)
}
// Die vorhandenen Daten müssen unangetastet sein.
existingContent, _ := os.ReadFile(wichtigeDatei)
if string(existingContent) != "Produktionsdaten" {
testInstance.Fatal("die vorhandenen Daten wurden verändert")
}
}
func TestRestoreSkipsExistingFilesWithoutOverwrite(testInstance *testing.T) {
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "teilweise",
SourcePath: sourcePath,
}); backupError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
}
targetPath := filepath.Join(testInstance.TempDir(), "ziel")
// Der erste Lauf schreibt alles.
firstResult, _ := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{
BackupID: "teilweise",
TargetPath: targetPath,
})
// Der zweite Lauf trifft auf vorhandene Dateien und übergeht sie.
secondResult, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{
BackupID: "teilweise",
TargetPath: targetPath,
OverwriteExisting: true,
})
if restoreError != nil {
testInstance.Fatalf("der zweite Lauf schlug fehl: %v", restoreError)
}
// Mit ausdrücklicher Erlaubnis werden sie ersetzt.
if secondResult.FilesRestored != firstResult.FilesRestored {
testInstance.Errorf("mit Erlaubnis sollen alle %d Dateien geschrieben werden, waren %d",
firstResult.FilesRestored, secondResult.FilesRestored)
}
}
func TestRestoreRejectsPathEscapingManifest(testInstance *testing.T) {
targetPath := testInstance.TempDir()
// Ein Manifest kann aus einem fremden Repository stammen. Ein Pfad wie
// "../../etc/passwd" schriebe an eine beliebige Stelle des Dateisystems.
maliciousPaths := []string{
"../ausserhalb.txt",
"../../etc/passwd",
"unterordner/../../ausbruch.txt",
"/absoluter/pfad.txt",
}
for _, maliciousPath := range maliciousPaths {
validationError := validateManifestPath(maliciousPath, targetPath)
if !errors.Is(validationError, ErrUnsafeManifestPath) {
testInstance.Errorf("der Pfad %q wurde akzeptiert", maliciousPath)
}
}
// Zulässige Pfade müssen weiterhin durchgehen.
for _, harmlessPath := range []string{"datei.txt", "ordner/datei.txt", "a/b/c/tief.txt"} {
if validationError := validateManifestPath(harmlessPath, targetPath); validationError != nil {
testInstance.Errorf("der zulässige Pfad %q wurde abgelehnt: %v", harmlessPath, validationError)
}
}
}
// ---------------------------------------------------------------------------
// Teilfehler
// ---------------------------------------------------------------------------
func TestBackupWithUnreadableFileIsPartialFailure(testInstance *testing.T) {
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
testInstance.Skip("der Test setzt Unix-Rechte und ein unprivilegiertes Konto voraus")
}
backupRunner, _ := newRoundTripEnvironment(testInstance)
sourcePath := buildTestTree(testInstance, map[string]string{
"lesbar.txt": "kann gelesen werden",
"gesperrt/geheim.txt": "kein Zugriff",
})
gesperrtesVerzeichnis := filepath.Join(sourcePath, "gesperrt")
_ = os.Chmod(gesperrtesVerzeichnis, 0o000)
testInstance.Cleanup(func() { _ = os.Chmod(gesperrtesVerzeichnis, 0o755) })
backupResult, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "teilfehler",
SourcePath: sourcePath,
})
if backupError != nil {
testInstance.Fatalf("die Sicherung soll trotz Rechtefehler durchlaufen: %v", backupError)
}
// Ein Lauf mit übergangenen Objekten ist ein Teilfehler, kein Erfolg.
if !backupResult.IsPartialFailure() {
testInstance.Fatal("die Sicherung wurde fälschlich als voller Erfolg gemeldet")
}
if !strings.Contains(backupResult.Summary(), "TEILWEISE FEHLGESCHLAGEN") {
testInstance.Errorf("die Zusammenfassung beschönigt den Ausgang: %s", backupResult.Summary())
}
}
func TestBackupCanAbortOnProblems(testInstance *testing.T) {
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
testInstance.Skip("der Test setzt Unix-Rechte und ein unprivilegiertes Konto voraus")
}
backupRunner, _ := newRoundTripEnvironment(testInstance)
sourcePath := buildTestTree(testInstance, map[string]string{
"lesbar.txt": "ok",
"gesperrt/geheim.txt": "kein Zugriff",
})
gesperrtesVerzeichnis := filepath.Join(sourcePath, "gesperrt")
_ = os.Chmod(gesperrtesVerzeichnis, 0o000)
testInstance.Cleanup(func() { _ = os.Chmod(gesperrtesVerzeichnis, 0o755) })
// Wer eine vollständige Sicherung verlangt, soll bei Problemen abbrechen können.
_, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "streng",
SourcePath: sourcePath,
AbortOnProblems: true,
})
if !errors.Is(backupError, ErrBackupIncompleteDueToProblems) {
testInstance.Fatalf("die Sicherung soll bei Problemen abbrechen, war: %v", backupError)
}
}
func TestBackupRefusesEmptySource(testInstance *testing.T) {
backupRunner, _ := newRoundTripEnvironment(testInstance)
// Ein leeres Backup wäre ein Gebilde, das sich als erfolgreiche Sicherung
// ausgibt, ohne etwas zu enthalten.
if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "leer",
SourcePath: testInstance.TempDir(),
}); backupError == nil {
testInstance.Fatal("eine leere Quelle muss abgelehnt werden")
}
}
// ---------------------------------------------------------------------------
// Deduplizierung über Läufe hinweg
// ---------------------------------------------------------------------------
func TestSecondBackupOfUnchangedTreeIsDeduplicated(testInstance *testing.T) {
backupRunner, _ := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
firstResult, _ := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "lauf-1",
SourcePath: sourcePath,
CompressionLevel: backupengine.CompressionBalanced,
EncryptionEnabled: true,
})
secondResult, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "lauf-2",
SourcePath: sourcePath,
CompressionLevel: backupengine.CompressionBalanced,
EncryptionEnabled: true,
})
if backupError != nil {
testInstance.Fatalf("der zweite Lauf schlug fehl: %v", backupError)
}
// Ein unveränderter Baum darf beim zweiten Lauf nichts neu schreiben —
// auch nicht bei eingeschalteter Verschlüsselung.
if secondResult.Progress.ChunksWritten != 0 {
testInstance.Errorf("der zweite Lauf soll nichts neu schreiben, waren %d Blöcke",
secondResult.Progress.ChunksWritten)
}
if secondResult.Progress.ChunksDeduplicated != firstResult.Progress.ChunksWritten {
testInstance.Errorf("es sollen %d Blöcke wiederverwendet werden, waren %d",
firstResult.Progress.ChunksWritten, secondResult.Progress.ChunksDeduplicated)
}
}
// TestResumeOverwritesOwnLeftoversAndCleansUp prueft die Fortsetzung nach einem Absturz.
//
// Regressionstest zu zwei Funden aus dem Nachweis der Phase 18:
//
// Erstens meldete eine Fortsetzung einen **Teilfehler**, obwohl das Ergebnis
// vollstaendig war: Die Dateien zwischen Pruefpunkt und tatsaechlichem
// Fortschritt lagen bereits am Platz und wurden uebergangen. Im Nachweis traf
// das 85 von 1500 Dateien. Ein Teilfehler, der keiner ist, ist genau die
// Meldung, die beim naechsten Mal niemand mehr liest.
//
// Zweitens blieb die beim harten Ende gerade geschriebene Datei unter ihrem
// temporaeren Namen liegen — dauerhaft. Im Nachweis lagen 1501 Dateien in einem
// Ziel, das 1500 enthalten sollte.
func TestResumeOverwritesOwnLeftoversAndCleansUp(testInstance *testing.T) {
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "fortsetzung",
SourcePath: sourcePath,
}); backupError != nil {
testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError)
}
targetPath := filepath.Join(testInstance.TempDir(), "ziel")
// Ein vollstaendiger Lauf stellt die Ausgangslage her.
firstResult, firstError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{
BackupID: "fortsetzung",
TargetPath: targetPath,
})
if firstError != nil {
testInstance.Fatalf("der erste Lauf schlug fehl: %v", firstError)
}
// Ein Rest, wie ihn ein harter Abbruch hinterlaesst.
leftoverPath := filepath.Join(targetPath, ".syncova-restore-1234567")
if writeError := os.WriteFile(leftoverPath, []byte("halbe Datei"), 0o600); writeError != nil {
testInstance.Fatalf("der Rest ließ sich nicht anlegen: %v", writeError)
}
// Die Fortsetzung setzt vor dem ersten Eintrag an: Damit liegen **alle**
// Dateien bereits am Platz — der schaerfste Fall des Fundes.
resumeResult, resumeError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{
BackupID: "fortsetzung",
TargetPath: targetPath,
ResumeAfterPath: " ", // vor jedem echten Pfad, aber nicht leer
})
if resumeError != nil {
testInstance.Fatalf("die Fortsetzung schlug fehl: %v", resumeError)
}
// Kein uebergangenes Objekt: Die vorhandenen Dateien stammen aus dem
// eigenen Lauf und werden ersetzt, nicht uebersprungen.
if resumeResult.SkippedExisting != 0 {
testInstance.Errorf("die Fortsetzung übersprang %d vorhandene Objekte; sie stammen aus "+
"dem eigenen abgebrochenen Lauf und machen aus einer vollständigen "+
"Wiederherstellung einen Teilfehler", resumeResult.SkippedExisting)
}
if resumeResult.FilesRestored != firstResult.FilesRestored {
testInstance.Errorf("die Fortsetzung schrieb %d Dateien, der erste Lauf %d",
resumeResult.FilesRestored, firstResult.FilesRestored)
}
// Der Rest muss verschwunden sein.
if _, statError := os.Stat(leftoverPath); statError == nil {
testInstance.Error("der Rest des abgebrochenen Laufs liegt noch im Zielverzeichnis")
}
if resumeResult.LeftoversRemoved != 1 {
testInstance.Errorf("es wurden %d Reste entfernt, erwartet wurde einer",
resumeResult.LeftoversRemoved)
}
}