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