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

274 lines
9.8 KiB
Go

package retention
import (
"errors"
"testing"
"time"
)
// referenceTimeForTests ist der feste Bezugszeitpunkt aller Tests.
//
// Ein fester Zeitpunkt statt time.Now: Sonst laege das Ergebnis mancher Tests
// am Wochentag der Ausfuehrung, und ein Fehler faende sich nur montags.
var referenceTimeForTests = time.Date(2026, time.August, 11, 12, 0, 0, 0, time.UTC)
// dailyBackups erzeugt taegliche Backups rueckwaerts ab dem Bezugszeitpunkt.
func dailyBackups(dayCount int) []BackupCandidate {
candidates := make([]BackupCandidate, 0, dayCount)
for dayOffset := 0; dayOffset < dayCount; dayOffset++ {
completionTime := referenceTimeForTests.AddDate(0, 0, -dayOffset)
candidates = append(candidates, BackupCandidate{
BackupID: completionTime.Format("2006-01-02"),
CompletedAt: completionTime,
SizeBytes: 1024,
})
}
return candidates
}
// TestEmptyPolicyIsRejected prueft die Ablehnung einer Regel ohne Haltevorgabe.
//
// Eine leere Regel als „behalte nichts" auszufuehren waere die teuerste
// moegliche Auslegung eines Tippfehlers.
func TestEmptyPolicyIsRejected(testInstance *testing.T) {
emptyPolicy := Policy{Name: "leer"}
if validationError := emptyPolicy.Validate(); !errors.Is(validationError, ErrPolicyEmpty) {
testInstance.Fatalf("die leere Regel wurde angenommen, Fehler war: %v", validationError)
}
if _, applyError := Apply(emptyPolicy, dailyBackups(5), referenceTimeForTests); applyError == nil {
testInstance.Fatal("die leere Regel wurde angewandt")
}
}
// TestKeepWithinKeepsRecentBackups prueft die einfache Frist.
func TestKeepWithinKeepsRecentBackups(testInstance *testing.T) {
plan, applyError := Apply(PolicySevenDays, dailyBackups(20), referenceTimeForTests)
if applyError != nil {
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
}
// Sieben Tage Frist: heute plus die sechs Vortage liegen innerhalb.
if plan.KeptCount != 7 {
testInstance.Errorf("es blieben %d Backups, erwartet wurden 7", plan.KeptCount)
}
if plan.DeletableCount != 13 {
testInstance.Errorf("es waeren %d loeschbar, erwartet wurden 13", plan.DeletableCount)
}
for _, decision := range plan.Decisions {
if decision.Reason == "" {
testInstance.Errorf("die Entscheidung fuer %s wurde nicht begruendet", decision.BackupID)
}
}
}
// TestKeepLastProtectsTheOnlyRemainingBackup prueft die wichtigste Sicherung.
//
// Der Fall ist die haeufigste Katastrophe einer Aufbewahrungsregel: Ein System
// sichert monatelang nicht, alle Backups fallen aus der Frist — und die Regel
// loescht das letzte vorhandene. Danach gibt es keines mehr.
func TestKeepLastProtectsTheOnlyRemainingBackup(testInstance *testing.T) {
oldBackup := []BackupCandidate{{
BackupID: "das-einzige",
CompletedAt: referenceTimeForTests.AddDate(0, -6, 0),
SizeBytes: 4096,
}}
plan, applyError := Apply(PolicySevenDays, oldBackup, referenceTimeForTests)
if applyError != nil {
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
}
if plan.DeletableCount != 0 {
testInstance.Fatal("das einzige vorhandene Backup wurde zum Loeschen vorgesehen")
}
if plan.Decisions[0].Reason == "" {
testInstance.Error("das Behalten wurde nicht begruendet")
}
}
// TestGrandfatherFatherSonKeepsOnePerInterval prueft die Staffelung.
func TestGrandfatherFatherSonKeepsOnePerInterval(testInstance *testing.T) {
// Zwei Jahre taeglicher Backups.
plan, applyError := Apply(PolicyGrandfatherFatherSon, dailyBackups(730), referenceTimeForTests)
if applyError != nil {
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
}
// 30 Tages-, 8 Wochen-, 12 Monats- und 7 Jahresstaende. Die Mengen
// ueberschneiden sich, deshalb wird nur die Groessenordnung geprueft — und
// vor allem, dass nicht alles behalten wird.
if plan.KeptCount < 30 {
testInstance.Errorf("es blieben nur %d Backups, das ist weniger als die 30 Tagesstaende", plan.KeptCount)
}
if plan.KeptCount > 60 {
testInstance.Errorf("es blieben %d Backups; die Staffelung greift offenbar nicht", plan.KeptCount)
}
if plan.DeletableCount == 0 {
testInstance.Error("die Staffelung entfernte kein einziges Backup")
}
// Das juengste bleibt in jedem Fall.
if !plan.Decisions[0].Keep {
testInstance.Error("das juengste Backup wurde zum Loeschen vorgesehen")
}
}
// TestRetentionNeverDeletesProtectedBackups prueft den Vorrang des Schutzes.
func TestRetentionNeverDeletesProtectedBackups(testInstance *testing.T) {
protectedUntil := referenceTimeForTests.AddDate(0, 1, 0)
candidates := []BackupCandidate{
{BackupID: "juengstes", CompletedAt: referenceTimeForTests},
{
BackupID: "alt-aber-geschuetzt",
CompletedAt: referenceTimeForTests.AddDate(0, 0, -100),
ImmutableUntil: &protectedUntil,
},
{
BackupID: "alt-mit-legal-hold",
CompletedAt: referenceTimeForTests.AddDate(0, 0, -200),
LegalHold: true,
},
{BackupID: "alt-und-frei", CompletedAt: referenceTimeForTests.AddDate(0, 0, -300)},
}
plan, applyError := Apply(PolicySevenDays, candidates, referenceTimeForTests)
if applyError != nil {
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
}
deletableIdentifiers := plan.DeletableBackupIDs()
if len(deletableIdentifiers) != 1 || deletableIdentifiers[0] != "alt-und-frei" {
testInstance.Fatalf("loeschbar waren %v, erwartet wurde nur alt-und-frei", deletableIdentifiers)
}
if plan.ProtectedCount != 2 {
testInstance.Errorf("es wurden %d geschuetzte Backups gemeldet, erwartet wurden 2", plan.ProtectedCount)
}
// Der Bericht muss den Unterschied benennen: „waere faellig, ist aber
// geschuetzt" erklaert, warum der Speicher nicht kleiner wird.
for _, decision := range plan.Decisions {
if decision.BackupID == "alt-aber-geschuetzt" && !decision.Protected {
testInstance.Error("das geschuetzte Backup wurde nicht als geschuetzt ausgewiesen")
}
}
}
// TestRetentionKeepsIncrementalParents prueft den Schutz der Elternbackups.
func TestRetentionKeepsIncrementalParents(testInstance *testing.T) {
candidates := []BackupCandidate{
{BackupID: "juengstes", CompletedAt: referenceTimeForTests},
{
BackupID: "eltern",
CompletedAt: referenceTimeForTests.AddDate(0, 0, -90),
IsIncrementalParent: true,
},
}
plan, applyError := Apply(PolicySevenDays, candidates, referenceTimeForTests)
if applyError != nil {
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
}
if plan.DeletableCount != 0 {
testInstance.Fatalf("es wurde ein Elternbackup zum Loeschen vorgesehen: %v", plan.DeletableBackupIDs())
}
}
// TestTimeZoneChangesDailyBuckets prueft die Zeitzone der Tagesgrenzen.
//
// Wer taeglich um 00:30 Uhr deutscher Zeit sichert, hat in UTC Backups vom
// Vortag um 22:30. Ohne Zeitzone fielen zwei Backups in denselben UTC-Tag und
// eines verschwaende — systematisch, und immer dasselbe.
func TestTimeZoneChangesDailyBuckets(testInstance *testing.T) {
berlinLocation, locationError := time.LoadLocation("Europe/Berlin")
if locationError != nil {
testInstance.Skipf("die Zeitzonendatenbank fehlt: %v", locationError)
}
// Zwei Backups am selben Berliner Tag, aber an zwei UTC-Tagen.
firstBackupTime := time.Date(2026, time.August, 10, 0, 30, 0, 0, berlinLocation)
secondBackupTime := time.Date(2026, time.August, 10, 23, 30, 0, 0, berlinLocation)
candidates := []BackupCandidate{
{BackupID: "spaet", CompletedAt: secondBackupTime},
{BackupID: "frueh", CompletedAt: firstBackupTime},
}
// Zwei Tagesstaende: In Berliner Zeit gibt es nur einen Tag zu halten, in
// UTC zwei. Mit nur einem Tagesstand waere der Unterschied nicht sichtbar —
// beide Auslegungen behielten dann genau ein Backup.
berlinPolicy := Policy{Name: "taeglich-berlin", KeepDaily: 2, TimeZone: "Europe/Berlin"}
berlinPlan, applyError := Apply(berlinPolicy, candidates, referenceTimeForTests)
if applyError != nil {
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
}
// In Berliner Zeit ist das derselbe Tag: nur eines bleibt.
if berlinPlan.KeptCount != 1 {
testInstance.Errorf("in Europe/Berlin blieben %d Backups, erwartet wurde 1", berlinPlan.KeptCount)
}
utcPolicy := Policy{Name: "taeglich-utc", KeepDaily: 2}
utcPlan, utcError := Apply(utcPolicy, candidates, referenceTimeForTests)
if utcError != nil {
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", utcError)
}
// In UTC sind es zwei Tage: beide bleiben. Genau dieser Unterschied ist der
// Grund, warum die Zeitzone Teil der Regel ist.
if utcPlan.KeptCount != 2 {
testInstance.Errorf("in UTC blieben %d Backups, erwartet wurden 2", utcPlan.KeptCount)
}
}
// TestApplyOnEmptyRepositoryDeletesNothing prueft den leeren Fall.
func TestApplyOnEmptyRepositoryDeletesNothing(testInstance *testing.T) {
plan, applyError := Apply(PolicyThirtyDays, nil, referenceTimeForTests)
if applyError != nil {
testInstance.Fatalf("die Regel liess sich nicht anwenden: %v", applyError)
}
if plan.DeletableCount != 0 || plan.KeptCount != 0 {
testInstance.Error("auf einem leeren Repository wurde eine Entscheidung getroffen")
}
if plan.Summary() == "" {
testInstance.Error("der leere Plan wurde nicht zusammengefasst")
}
}
// TestPredefinedPoliciesAreValid prueft die mitgelieferten Regeln.
//
// Eine mitgelieferte Regel, die die eigene Pruefung nicht besteht, waere eine
// Falle mit Werkseinstellung.
func TestPredefinedPoliciesAreValid(testInstance *testing.T) {
for _, predefinedPolicy := range PredefinedPolicies() {
if validationError := predefinedPolicy.Validate(); validationError != nil {
testInstance.Errorf("die Regel %q ist unbrauchbar: %v", predefinedPolicy.Name, validationError)
}
if predefinedPolicy.KeepLast < 1 {
testInstance.Errorf("die Regel %q schuetzt das letzte Backup nicht", predefinedPolicy.Name)
}
if predefinedPolicy.Describe() == "" {
testInstance.Errorf("die Regel %q erklaert sich nicht", predefinedPolicy.Name)
}
}
}