package retention import ( "context" "fmt" "io" "io/fs" "log/slog" "path/filepath" "testing" "time" "github.com/syncova/syncova/packages/repository" ) // discardLogger liefert einen Logger, der nichts ausgibt. func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // newTestRepository legt ein Repository fuer einen Test an. func newTestRepository(testInstance *testing.T, createOptions repository.CreateOptions) *repository.LocalRepository { testInstance.Helper() repositoryRoot := testInstance.TempDir() createOptions.CreatedByVersion = "test" createdRepository, createError := repository.Create(context.Background(), repositoryRoot, createOptions, discardLogger()) if createError != nil { testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError) } testInstance.Cleanup(func() { _ = createdRepository.Close() }) // Geschuetzte Dateien verhindern das Aufraeumen von t.TempDir — genau das // ist ihr Zweck. Ohne diese Freigabe blieben Verzeichnisse zurueck, die sich // auch von Hand kaum entfernen lassen. testInstance.Cleanup(func() { _ = filepath.WalkDir(repositoryRoot, func(entryPath string, directoryEntry fs.DirEntry, walkError error) error { if walkError != nil || directoryEntry.IsDir() { return nil } _ = repository.ReleaseImmutableFlagForMaintenance(entryPath) return nil }) }) return createdRepository } // writeBackup schreibt ein Backup in das Repository. // // Der Abschlusszeitpunkt laesst sich **nicht** vorgeben: Das Repository setzt // ihn selbst (Phase 2 — Manifest-Kennzahlen stammen immer aus der Session, nie // vom Aufrufer). Wer altes Backup braucht, verschiebt deshalb die Gegenwart des // Enforcers, nicht die Vergangenheit des Manifests. Die Reihenfolge der Aufrufe // bestimmt, welches Backup als juengstes gilt. func writeBackup(testInstance *testing.T, targetRepository *repository.LocalRepository, backupID string, payload []byte) { testInstance.Helper() backupWriter, beginError := targetRepository.BeginBackup(context.Background(), backupID) if beginError != nil { testInstance.Fatalf("die Schreibsession liess sich nicht oeffnen: %v", beginError) } chunkReference, _, writeError := backupWriter.WriteChunk(context.Background(), payload) if writeError != nil { testInstance.Fatalf("der Block liess sich nicht schreiben: %v", writeError) } manifest := &repository.Manifest{ Source: repository.SourceInformation{ SourceType: "filesystem", SourceID: "quelle", SourceName: "Testquelle", }, Entries: []repository.ManifestEntry{{ Path: "/daten/" + backupID + ".bin", EntryType: "file", SizeBytes: chunkReference.LogicalLength, Chunks: []repository.ChunkReference{chunkReference}, }}, CreatedByVersion: "test", } if commitError := backupWriter.Commit(context.Background(), manifest); commitError != nil { testInstance.Fatalf("das Backup liess sich nicht abschliessen: %v", commitError) } } // TestPreviewDoesNotDeleteAnything prueft, dass die Vorschau nichts anfasst. // // Wenn irgendetwas in diesem Paket harmlos sein muss, dann die Vorschau: Sie ist // der Weg, eine Regel gefahrlos auszuprobieren. func TestPreviewDoesNotDeleteAnything(testInstance *testing.T) { testRepository := newTestRepository(testInstance, repository.CreateOptions{Name: "vorschau"}) for backupIndex := 0; backupIndex < 10; backupIndex++ { writeBackup(testInstance, testRepository, fmt.Sprintf("backup-%02d", backupIndex), []byte("nutzdaten "+string(rune('a'+backupIndex)))) } enforcer, enforcerError := NewEnforcer(testRepository, discardLogger()) if enforcerError != nil { testInstance.Fatalf("der Enforcer liess sich nicht bauen: %v", enforcerError) } // Die Gegenwart wird vorgestellt: Aus Sicht des Enforcers sind alle Backups // hundert Tage alt. enforcer.timeSource = func() time.Time { return time.Now().AddDate(0, 0, 100) } plan, previewError := enforcer.Preview(context.Background(), PolicySevenDays) if previewError != nil { testInstance.Fatalf("die Vorschau schlug fehl: %v", previewError) } if plan.DeletableCount == 0 { testInstance.Fatal("die Vorschau sah keine Loeschung vor; der Test prueft damit nichts") } backupsAfterPreview, listError := testRepository.ListBackups(context.Background()) if listError != nil { testInstance.Fatalf("die Backups liessen sich nicht lesen: %v", listError) } if len(backupsAfterPreview) != 10 { testInstance.Errorf("nach der Vorschau sind %d von 10 Backups uebrig", len(backupsAfterPreview)) } } // TestExecuteDeletesOnlyUnprotectedBackups ist der Kerntest der Aufbewahrung. func TestExecuteDeletesOnlyUnprotectedBackups(testInstance *testing.T) { testRepository := newTestRepository(testInstance, repository.CreateOptions{Name: "anwendung"}) // Aeltestes zuerst: Der Abschlusszeitpunkt entsteht beim Commit, die // Schreibreihenfolge legt damit das Alter fest. writeBackup(testInstance, testRepository, "alt-mit-hold", []byte("beweisdaten")) writeBackup(testInstance, testRepository, "alt-und-frei", []byte("alte daten")) writeBackup(testInstance, testRepository, "juengstes", []byte("aktuelle daten")) if _, holdError := testRepository.PlaceLegalHold(context.Background(), "alt-mit-hold", "auditor", "Rechtsstreit"); holdError != nil { testInstance.Fatalf("der Legal Hold liess sich nicht setzen: %v", holdError) } enforcer, _ := NewEnforcer(testRepository, discardLogger()) enforcer.timeSource = func() time.Time { return time.Now().AddDate(0, 0, 100) } executionResult, executeError := enforcer.Execute(context.Background(), PolicySevenDays) if executeError != nil { testInstance.Fatalf("die Anwendung schlug fehl: %v", executeError) } if len(executionResult.DeletedBackupIDs) != 1 || executionResult.DeletedBackupIDs[0] != "alt-und-frei" { testInstance.Fatalf("geloescht wurden %v, erwartet wurde nur alt-und-frei", executionResult.DeletedBackupIDs) } if executionResult.IsPartialFailure() { testInstance.Errorf("die Anwendung meldete Fehler: %v", executionResult.Failures) } // Die Bereinigung muss den Speicher tatsaechlich freigeben — sonst waeren // die Backups weg und der Platz trotzdem belegt. if executionResult.ChunksRemoved == 0 { testInstance.Error("es wurde kein verwaister Block bereinigt") } remainingBackups, _ := testRepository.ListBackups(context.Background()) if len(remainingBackups) != 2 { testInstance.Errorf("es blieben %d Backups, erwartet wurden 2", len(remainingBackups)) } } // TestExecuteRespectsRepositoryRetentionLock prueft den Vorrang des Repositorys. // // Der Fall ist der eigentliche Zweck von Phase 11: Eine Aufbewahrungsregel sagt // „loeschen", der Aufbewahrungsschutz sagt „nein". Das Repository gewinnt. func TestExecuteRespectsRepositoryRetentionLock(testInstance *testing.T) { testRepository := newTestRepository(testInstance, repository.CreateOptions{ Name: "geschuetzt", Immutable: true, Retention: 365 * 24 * time.Hour, }) writeBackup(testInstance, testRepository, "alt", []byte("alt")) writeBackup(testInstance, testRepository, "juengstes", []byte("aktuell")) enforcer, _ := NewEnforcer(testRepository, discardLogger()) // Hundert Tage spaeter: Die Regel wuerde loeschen, der Aufbewahrungsschutz // des Repositorys laeuft aber ein Jahr. enforcer.timeSource = func() time.Time { return time.Now().AddDate(0, 0, 100) } plan, previewError := enforcer.Preview(context.Background(), PolicySevenDays) if previewError != nil { testInstance.Fatalf("die Vorschau schlug fehl: %v", previewError) } // Der Plan muss den Widerspruch benennen, statt das Backup einfach still zu // behalten: Der Betreiber soll erfahren, warum sein Speicher nicht kleiner // wird. // Das juengste haelt bereits die Regel selbst (KeepLast); geschuetzt ist // also genau das aeltere, das die Regel loeschen wollte. if plan.ProtectedCount != 1 { testInstance.Errorf("es wurde %d geschuetztes Backup gemeldet, erwartet wurde 1", plan.ProtectedCount) } executionResult, executeError := enforcer.Execute(context.Background(), PolicySevenDays) if executeError != nil { testInstance.Fatalf("die Anwendung schlug fehl: %v", executeError) } if len(executionResult.DeletedBackupIDs) != 0 { testInstance.Fatalf("trotz Aufbewahrungsschutz wurden Backups geloescht: %v", executionResult.DeletedBackupIDs) } remainingBackups, _ := testRepository.ListBackups(context.Background()) if len(remainingBackups) != 2 { testInstance.Errorf("es blieben %d Backups, erwartet wurden 2", len(remainingBackups)) } } // TestExecuteDeletesSelfContainedChainParents prueft die Aufbewahrung in einer Kette. // // Der Fall entscheidet, ob die Aufbewahrung ueberhaupt etwas tut. In einer // fortlaufenden Kette ist **jedes** Backup ausser dem juengsten ein Elternteil; // wer sie alle schuetzt, raeumt nie auf — waehrend der Betreiber glaubt, eine // Aufbewahrung zu haben. // // Bei Syncova traegt jedes Manifest die Blockverweise aller Objekte (Phase 6). // Deshalb darf das Elternbackup gehen, und der Test weist nach, dass das Kind // danach **vollstaendig lesbar** bleibt — die Behauptung allein genuegt nicht. func TestExecuteDeletesSelfContainedChainParents(testInstance *testing.T) { testRepository := newTestRepository(testInstance, repository.CreateOptions{Name: "kette"}) writeBackup(testInstance, testRepository, "voll", []byte("vollsicherung")) // Eine Zusatzsicherung, die auf der alten Vollsicherung aufbaut. incrementalWriter, beginError := testRepository.BeginBackup(context.Background(), "zusatz") if beginError != nil { testInstance.Fatalf("die Schreibsession liess sich nicht oeffnen: %v", beginError) } chunkReference, _, writeError := incrementalWriter.WriteChunk(context.Background(), []byte("zusatzdaten")) if writeError != nil { testInstance.Fatalf("der Block liess sich nicht schreiben: %v", writeError) } commitError := incrementalWriter.Commit(context.Background(), &repository.Manifest{ Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "q", SourceName: "Q"}, BackupType: repository.BackupTypeIncremental, ParentBackupID: "voll", Entries: []repository.ManifestEntry{{ Path: "/daten/zusatz.bin", EntryType: "file", SizeBytes: chunkReference.LogicalLength, Chunks: []repository.ChunkReference{chunkReference}, }}, CreatedByVersion: "test", }) if commitError != nil { testInstance.Fatalf("die Zusatzsicherung liess sich nicht abschliessen: %v", commitError) } enforcer, _ := NewEnforcer(testRepository, discardLogger()) enforcer.timeSource = func() time.Time { return time.Now().AddDate(0, 0, 100) } // Die Bloecke der Zusatzsicherung werden vor dem Aufraeumen festgehalten: // Nach dem Loeschen des Elternteils muessen sie alle noch da sein. incrementalManifest, manifestError := testRepository.ReadManifest(context.Background(), "zusatz") if manifestError != nil { testInstance.Fatalf("das Manifest der Zusatzsicherung liess sich nicht lesen: %v", manifestError) } if !incrementalManifest.SelfContainedRestore { testInstance.Fatal("die Zusatzsicherung ist nicht als eigenstaendig gekennzeichnet; " + "ohne diese Zusicherung darf die Aufbewahrung ihr Elternbackup nicht loeschen") } requiredChunks := incrementalManifest.UniqueChunkIdentifiers() executionResult, executeError := enforcer.Execute(context.Background(), PolicySevenDays) if executeError != nil { testInstance.Fatalf("die Anwendung schlug fehl: %v", executeError) } if len(executionResult.DeletedBackupIDs) != 1 || executionResult.DeletedBackupIDs[0] != "voll" { testInstance.Fatalf("geloescht wurden %v, erwartet wurde die Vollsicherung", executionResult.DeletedBackupIDs) } // Der eigentliche Nachweis: Die Zusatzsicherung ist danach vollstaendig. for chunkIdentifier := range requiredChunks { if _, readError := testRepository.ReadChunk(context.Background(), chunkIdentifier); readError != nil { testInstance.Errorf("nach dem Loeschen des Elternbackups fehlt der Block %s: %v", chunkIdentifier, readError) } } } // TestExecuteNeverEmptiesRepository prueft die letzte Sicherung gegen Totalverlust. func TestExecuteNeverEmptiesRepository(testInstance *testing.T) { testRepository := newTestRepository(testInstance, repository.CreateOptions{Name: "letztes"}) writeBackup(testInstance, testRepository, "das-letzte", []byte("die einzigen daten")) enforcer, _ := NewEnforcer(testRepository, discardLogger()) // Zwei Jahre spaeter — der Fall eines Systems, das lange nicht gesichert hat. enforcer.timeSource = func() time.Time { return time.Now().AddDate(2, 0, 0) } executionResult, executeError := enforcer.Execute(context.Background(), PolicySevenDays) if executeError != nil { testInstance.Fatalf("die Anwendung schlug fehl: %v", executeError) } if len(executionResult.DeletedBackupIDs) != 0 { testInstance.Fatal("die Aufbewahrungsregel loeschte das einzige vorhandene Backup") } remainingBackups, _ := testRepository.ListBackups(context.Background()) if len(remainingBackups) != 1 { testInstance.Fatal("das Repository ist leer") } }