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

436 lines
17 KiB
Go

package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/syncova/syncova/packages/repository"
)
// TestDetectChangesFindsAddedModifiedAndDeleted prüft die drei Grundfälle.
func TestDetectChangesFindsAddedModifiedAndDeleted(testInstance *testing.T) {
// Das Elternbackup liegt eine Stunde zurück; damit greift die
// Zeitstempel-Schutzregel nicht und die eigentlichen Fälle bleiben sichtbar.
parentStart := time.Now().UTC().Add(-time.Hour)
fileTime := parentStart.Add(-time.Minute)
parentManifest := &repository.Manifest{
BackupID: "eltern",
StartedAt: parentStart,
Entries: []repository.ManifestEntry{
{Path: "gleich.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644",
Chunks: []repository.ChunkReference{{Identifier: "aaa", LogicalLength: 100}}},
{Path: "geaendert.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644",
Chunks: []repository.ChunkReference{{Identifier: "bbb", LogicalLength: 100}}},
{Path: "weg.txt", EntryType: "file", SizeBytes: 50, ModifiedAt: fileTime, Mode: "0644",
Chunks: []repository.ChunkReference{{Identifier: "ccc", LogicalLength: 50}}},
},
}
discoveredEntries := []DiscoveredEntry{
{RelativePath: "gleich.txt", EntryType: EntryTypeFile, SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644"},
{RelativePath: "geaendert.txt", EntryType: EntryTypeFile, SizeBytes: 250, ModifiedAt: fileTime, Mode: "0644"},
{RelativePath: "neu.txt", EntryType: EntryTypeFile, SizeBytes: 10, ModifiedAt: fileTime, Mode: "0644"},
}
changeSet := DetectChanges(discoveredEntries, parentManifest)
if changeSet.CountOf(ChangeKindUnchanged) != 1 {
testInstance.Errorf("es wurde genau ein unverändertes Objekt erwartet, gezählt wurden %d",
changeSet.CountOf(ChangeKindUnchanged))
}
if changeSet.CountOf(ChangeKindModified) != 1 {
testInstance.Errorf("es wurde genau ein geändertes Objekt erwartet, gezählt wurden %d",
changeSet.CountOf(ChangeKindModified))
}
if changeSet.CountOf(ChangeKindAdded) != 1 {
testInstance.Errorf("es wurde genau ein neues Objekt erwartet, gezählt wurden %d",
changeSet.CountOf(ChangeKindAdded))
}
if len(changeSet.DeletedPaths) != 1 || changeSet.DeletedPaths[0] != "weg.txt" {
testInstance.Errorf("die gelöschte Datei wurde nicht erkannt: %v", changeSet.DeletedPaths)
}
// Nur das Unveränderte darf Blockverweise übernehmen.
if changeSet.ReusedFileCount() != 1 {
testInstance.Errorf("es sollte genau eine Datei übernommen werden, es waren %d", changeSet.ReusedFileCount())
}
// Gelesen werden muss nur das Geänderte und das Neue.
expectedBytesToRead := int64(250 + 10)
if changeSet.BytesToRead() != expectedBytesToRead {
testInstance.Errorf("es sollten %d Byte gelesen werden, berechnet wurden %d",
expectedBytesToRead, changeSet.BytesToRead())
}
}
// TestDetectChangesTreatsSameSecondWriteAsModified prüft die Zeitstempel-Falle.
//
// Eine Datei, die während des Elternbackups geschrieben wurde, kann bei grober
// Zeitauflösung unverändert aussehen. Würde sie übernommen, ginge ihr neuer
// Inhalt still verloren — der schlimmste denkbare Fehler eines Backups.
func TestDetectChangesTreatsSameSecondWriteAsModified(testInstance *testing.T) {
parentStart := time.Now().UTC().Add(-time.Hour)
parentManifest := &repository.Manifest{
BackupID: "eltern",
StartedAt: parentStart,
Entries: []repository.ManifestEntry{
{Path: "waehrenddessen.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: parentStart, Mode: "0644",
Chunks: []repository.ChunkReference{{Identifier: "aaa", LogicalLength: 100}}},
},
}
// Größe und Zeitstempel stimmen exakt überein — das Verfahren müsste die
// Datei für unverändert halten, wenn es die Falle nicht kennte.
discoveredEntries := []DiscoveredEntry{
{RelativePath: "waehrenddessen.txt", EntryType: EntryTypeFile, SizeBytes: 100, ModifiedAt: parentStart, Mode: "0644"},
}
changeSet := DetectChanges(discoveredEntries, parentManifest)
if changeSet.Decisions[0].Kind != ChangeKindModified {
testInstance.Fatalf("eine während des Elternbackups geschriebene Datei muss als geändert gelten, erkannt wurde %q",
changeSet.Decisions[0].Kind)
}
if changeSet.Decisions[0].ParentEntry != nil {
testInstance.Error("eine als geändert erkannte Datei darf keine Blockverweise übernehmen")
}
}
// TestDetectChangesReusesContentOnPermissionChange prüft die reine Rechteänderung.
func TestDetectChangesReusesContentOnPermissionChange(testInstance *testing.T) {
parentStart := time.Now().UTC().Add(-time.Hour)
fileTime := parentStart.Add(-time.Minute)
parentManifest := &repository.Manifest{
BackupID: "eltern",
StartedAt: parentStart,
Entries: []repository.ManifestEntry{
{Path: "rechte.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644",
Chunks: []repository.ChunkReference{{Identifier: "aaa", LogicalLength: 100}}},
},
}
discoveredEntries := []DiscoveredEntry{
{RelativePath: "rechte.txt", EntryType: EntryTypeFile, SizeBytes: 100, ModifiedAt: fileTime, Mode: "0600"},
}
changeSet := DetectChanges(discoveredEntries, parentManifest)
if changeSet.Decisions[0].Kind != ChangeKindMetadataOnly {
testInstance.Fatalf("eine reine Rechteänderung wurde als %q eingestuft", changeSet.Decisions[0].Kind)
}
// Der Inhalt wird übernommen, die neuen Rechte werden dennoch vermerkt.
if changeSet.Decisions[0].ParentEntry == nil {
testInstance.Error("bei unverändertem Inhalt müssen die Blockverweise übernommen werden")
}
}
// TestDetectChangesRefusesEntryWithoutChunks prüft den Schutz gegen leere Elterneinträge.
func TestDetectChangesRefusesEntryWithoutChunks(testInstance *testing.T) {
parentStart := time.Now().UTC().Add(-time.Hour)
fileTime := parentStart.Add(-time.Minute)
parentManifest := &repository.Manifest{
BackupID: "eltern",
StartedAt: parentStart,
Entries: []repository.ManifestEntry{
// Ein Eintrag mit Größe, aber ohne Blockverweise darf nicht
// vorkommen. Käme er doch vor, wäre die Übernahme ein stiller
// Datenverlust.
{Path: "kaputt.txt", EntryType: "file", SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644"},
},
}
discoveredEntries := []DiscoveredEntry{
{RelativePath: "kaputt.txt", EntryType: EntryTypeFile, SizeBytes: 100, ModifiedAt: fileTime, Mode: "0644"},
}
changeSet := DetectChanges(discoveredEntries, parentManifest)
if changeSet.Decisions[0].Kind != ChangeKindModified {
testInstance.Fatalf("ein Elterneintrag ohne Blockverweise muss zum erneuten Lesen führen, erkannt wurde %q",
changeSet.Decisions[0].Kind)
}
}
// TestDetectChangesHandlesTypeSwitch prüft den Wechsel von Datei zu Verzeichnis.
func TestDetectChangesHandlesTypeSwitch(testInstance *testing.T) {
parentStart := time.Now().UTC().Add(-time.Hour)
parentManifest := &repository.Manifest{
BackupID: "eltern",
StartedAt: parentStart,
Entries: []repository.ManifestEntry{
{Path: "wechsel", EntryType: "file", SizeBytes: 100, ModifiedAt: parentStart.Add(-time.Minute), Mode: "0644",
Chunks: []repository.ChunkReference{{Identifier: "aaa", LogicalLength: 100}}},
},
}
discoveredEntries := []DiscoveredEntry{
{RelativePath: "wechsel", EntryType: EntryTypeDirectory, ModifiedAt: parentStart.Add(-time.Minute), Mode: "0755"},
}
changeSet := DetectChanges(discoveredEntries, parentManifest)
if changeSet.Decisions[0].Kind != ChangeKindModified {
testInstance.Fatalf("ein Typwechsel muss als Änderung gelten, erkannt wurde %q", changeSet.Decisions[0].Kind)
}
}
// TestDetectChangesWithoutParentTreatsEverythingAsNew prüft den Fall ohne Elternbackup.
func TestDetectChangesWithoutParentTreatsEverythingAsNew(testInstance *testing.T) {
discoveredEntries := []DiscoveredEntry{
{RelativePath: "a.txt", EntryType: EntryTypeFile, SizeBytes: 10},
{RelativePath: "b.txt", EntryType: EntryTypeFile, SizeBytes: 20},
}
changeSet := DetectChanges(discoveredEntries, nil)
if changeSet.CountOf(ChangeKindAdded) != 2 {
testInstance.Fatalf("ohne Elternbackup muss alles neu sein, gezählt wurden %d",
changeSet.CountOf(ChangeKindAdded))
}
if changeSet.BytesToRead() != 30 {
testInstance.Errorf("es müssen alle 30 Byte gelesen werden, berechnet wurden %d", changeSet.BytesToRead())
}
}
// TestIncrementalBackupReadsOnlyChangedData ist der eigentliche Nachweis:
// Eine Zusatzsicherung liest die unveränderten Dateien nicht mehr.
func TestIncrementalBackupReadsOnlyChangedData(testInstance *testing.T) {
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
backupContext := context.Background()
fullResult, fullError := backupRunner.RunBackup(backupContext, BackupRunOptions{
BackupID: "voll-1",
SourcePath: sourcePath,
SourceName: "Testquelle",
})
if fullError != nil {
testInstance.Fatalf("die Vollsicherung schlug fehl: %v", fullError)
}
if fullResult.BackupType != repository.BackupTypeFull {
testInstance.Errorf("die erste Sicherung muss eine Vollsicherung sein, sie war %q", fullResult.BackupType)
}
// Eine einzige Datei ändert sich; ihr Zeitstempel wird ausdrücklich in die
// Zukunft gesetzt, damit die Änderung unabhängig von der Zeitauflösung des
// Dateisystems erkennbar ist.
changedFilePath := filepath.Join(sourcePath, "dokumente", "notiz.txt")
if writeError := os.WriteFile(changedFilePath, []byte("Eine geänderte Notiz mit anderem Inhalt."), 0o600); writeError != nil {
testInstance.Fatalf("die Datei konnte nicht geändert werden: %v", writeError)
}
newFileTime := time.Now().Add(time.Second)
if timeError := os.Chtimes(changedFilePath, newFileTime, newFileTime); timeError != nil {
testInstance.Fatalf("der Zeitstempel konnte nicht gesetzt werden: %v", timeError)
}
incrementalResult, incrementalError := backupRunner.RunBackup(backupContext, BackupRunOptions{
BackupID: "zusatz-1",
SourcePath: sourcePath,
SourceName: "Testquelle",
Incremental: true,
})
if incrementalError != nil {
testInstance.Fatalf("die Zusatzsicherung schlug fehl: %v", incrementalError)
}
if incrementalResult.BackupType != repository.BackupTypeIncremental {
testInstance.Errorf("die zweite Sicherung muss eine Zusatzsicherung sein, sie war %q", incrementalResult.BackupType)
}
if incrementalResult.Changes == nil {
testInstance.Fatal("eine Zusatzsicherung muss das Ergebnis der Änderungserkennung ausweisen")
}
if incrementalResult.Changes.ParentBackupID != "voll-1" {
testInstance.Errorf("als Elternbackup wurde %q gewählt statt voll-1", incrementalResult.Changes.ParentBackupID)
}
// Der Kern des Nachweises: es wurde deutlich weniger gelesen als beim
// Vollbackup. Ohne diesen Vergleich bliebe offen, ob die Zusatzsicherung
// überhaupt etwas einspart.
if incrementalResult.Progress.BytesProcessed >= fullResult.Progress.BytesProcessed {
testInstance.Errorf("die Zusatzsicherung las %d Byte, die Vollsicherung nur %d — es wurde nichts eingespart",
incrementalResult.Progress.BytesProcessed, fullResult.Progress.BytesProcessed)
}
if incrementalResult.Progress.ChunksReused == 0 {
testInstance.Error("es wurde kein einziger Block aus dem Elternbackup übernommen")
}
if incrementalResult.Changes.ChangedFileCount() != 1 {
testInstance.Errorf("es sollte genau eine Datei neu gelesen werden, es waren %d",
incrementalResult.Changes.ChangedFileCount())
}
// Trotz Zusatzsicherung muss die Wiederherstellung den vollständigen
// Bestand ergeben — das Manifest beschreibt alles, nicht nur die Änderung.
sourceFingerprint := treeFingerprint(testInstance, sourcePath, true)
restoreTarget := filepath.Join(testInstance.TempDir(), "wiederhergestellt")
restoreResult, restoreError := restoreRunner.RunRestore(backupContext, RestoreRunOptions{
BackupID: "zusatz-1",
TargetPath: restoreTarget,
RestorePermissions: true,
})
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if restoreResult.FilesRestored != fullResult.FilesBackedUp {
testInstance.Errorf("aus der Zusatzsicherung kamen %d Dateien zurück, gesichert waren %d",
restoreResult.FilesRestored, fullResult.FilesBackedUp)
}
if restoredFingerprint := treeFingerprint(testInstance, restoreTarget, true); restoredFingerprint != sourceFingerprint {
testInstance.Fatal("die aus der Zusatzsicherung wiederhergestellten Daten weichen von der Quelle ab")
}
}
// TestIncrementalBackupDropsDeletedFiles prüft, dass Gelöschtes verschwindet
// und dabei benannt wird.
func TestIncrementalBackupDropsDeletedFiles(testInstance *testing.T) {
backupRunner, restoreRunner := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
backupContext := context.Background()
if _, fullError := backupRunner.RunBackup(backupContext, BackupRunOptions{
BackupID: "voll-1",
SourcePath: sourcePath,
SourceName: "Testquelle",
}); fullError != nil {
testInstance.Fatalf("die Vollsicherung schlug fehl: %v", fullError)
}
if removeError := os.Remove(filepath.Join(sourcePath, "dokumente", "bericht.txt")); removeError != nil {
testInstance.Fatalf("die Datei konnte nicht gelöscht werden: %v", removeError)
}
incrementalResult, incrementalError := backupRunner.RunBackup(backupContext, BackupRunOptions{
BackupID: "zusatz-1",
SourcePath: sourcePath,
SourceName: "Testquelle",
Incremental: true,
})
if incrementalError != nil {
testInstance.Fatalf("die Zusatzsicherung schlug fehl: %v", incrementalError)
}
deletedPaths := incrementalResult.Changes.DeletedPaths
if len(deletedPaths) != 1 || deletedPaths[0] != "dokumente/bericht.txt" {
testInstance.Fatalf("die gelöschte Datei wurde nicht ausgewiesen: %v", deletedPaths)
}
restoreTarget := filepath.Join(testInstance.TempDir(), "wiederhergestellt")
if _, restoreError := restoreRunner.RunRestore(backupContext, RestoreRunOptions{
BackupID: "zusatz-1",
TargetPath: restoreTarget,
}); restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if _, statError := os.Stat(filepath.Join(restoreTarget, "dokumente", "bericht.txt")); statError == nil {
testInstance.Fatal("die gelöschte Datei kam aus der Zusatzsicherung zurück")
}
// Das Elternbackup bleibt davon unberührt: aus ihm muss die Datei weiterhin
// zurückkommen. Genau dafür gibt es Aufbewahrung.
parentTarget := filepath.Join(testInstance.TempDir(), "eltern")
if _, restoreError := restoreRunner.RunRestore(backupContext, RestoreRunOptions{
BackupID: "voll-1",
TargetPath: parentTarget,
}); restoreError != nil {
testInstance.Fatalf("die Wiederherstellung des Elternbackups schlug fehl: %v", restoreError)
}
if _, statError := os.Stat(filepath.Join(parentTarget, "dokumente", "bericht.txt")); statError != nil {
testInstance.Fatalf("die gelöschte Datei fehlt im Elternbackup: %v", statError)
}
}
// TestIncrementalBackupWithoutParentFails prüft, dass eine Zusatzsicherung ohne
// Elternbackup nicht heimlich zur Vollsicherung wird.
//
// Ein stiller Wechsel wäre bequem und falsch: der Aufrufer glaubte, eine Kette
// fortzuschreiben, und hätte in Wahrheit eine neue begonnen.
func TestIncrementalBackupWithoutParentFails(testInstance *testing.T) {
backupRunner, _ := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
_, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{
BackupID: "zusatz-ohne-eltern",
SourcePath: sourcePath,
Incremental: true,
})
if backupError == nil {
testInstance.Fatal("eine Zusatzsicherung ohne Elternbackup muss fehlschlagen")
}
if !strings.Contains(backupError.Error(), "elternbackup") {
testInstance.Errorf("die Fehlermeldung nennt die Ursache nicht: %v", backupError)
}
}
// TestIncrementalChainKeepsChainIdentifier prüft, dass eine Kette
// zusammenbleibt.
func TestIncrementalChainKeepsChainIdentifier(testInstance *testing.T) {
backupRunner, _ := newRoundTripEnvironment(testInstance)
sourcePath := buildRealisticTree(testInstance)
backupContext := context.Background()
if _, fullError := backupRunner.RunBackup(backupContext, BackupRunOptions{
BackupID: "voll-1",
SourcePath: sourcePath,
ChainID: "kette-a",
}); fullError != nil {
testInstance.Fatalf("die Vollsicherung schlug fehl: %v", fullError)
}
// Zwei aufeinanderfolgende Zusatzsicherungen: die zweite muss die erste als
// Elternbackup wählen, nicht die Vollsicherung.
for _, backupIdentifier := range []string{"zusatz-1", "zusatz-2"} {
if _, incrementalError := backupRunner.RunBackup(backupContext, BackupRunOptions{
BackupID: backupIdentifier,
SourcePath: sourcePath,
Incremental: true,
}); incrementalError != nil {
testInstance.Fatalf("die Zusatzsicherung %s schlug fehl: %v", backupIdentifier, incrementalError)
}
}
sourceRepository := backupRunner.engine.Repository()
lastManifest, readError := sourceRepository.ReadManifest(backupContext, "zusatz-2")
if readError != nil {
testInstance.Fatalf("das Manifest konnte nicht gelesen werden: %v", readError)
}
if lastManifest.ChainID != "kette-a" {
testInstance.Errorf("die Kette wurde nicht fortgeschrieben: %q statt kette-a", lastManifest.ChainID)
}
if lastManifest.ParentBackupID != "zusatz-1" {
testInstance.Errorf("als Elternbackup wurde %q gewählt statt zusatz-1", lastManifest.ParentBackupID)
}
}