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