syncova-backup/packages/retention/enforcer_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

329 lines
13 KiB
Go

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