syncova-backup/packages/scheduler/schedule_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

448 lines
16 KiB
Go

package scheduler
import (
"errors"
"testing"
"time"
)
// mustLoadBerlin lädt die mitteleuropäische Zeitzone.
//
// Sie wird gebraucht, weil die Zeitumstellung nur in einer Zone mit Umstellung
// prüfbar ist. In UTC gäbe es die interessanten Fälle nicht.
func mustLoadBerlin(testInstance *testing.T) *time.Location {
testInstance.Helper()
berlinLocation, loadError := time.LoadLocation("Europe/Berlin")
if loadError != nil {
testInstance.Skipf("die Zeitzonendaten fehlen auf diesem System: %v", loadError)
}
return berlinLocation
}
// TestDailyScheduleSurvivesSpringForward ist der wichtigste Test dieser Datei.
//
// In Mitteleuropa springt die Uhr am letzten Sonntag im März von 02:00 auf
// 03:00. Ein Auftrag „täglich um 02:30" hat an diesem Tag keinen Zeitpunkt.
// Ein Scheduler, der das nicht bedenkt, lässt die Sicherung einmal im Jahr
// stillschweigend ausfallen — und niemand merkt es, bis man sie braucht.
func TestDailyScheduleSurvivesSpringForward(testInstance *testing.T) {
berlinLocation := mustLoadBerlin(testInstance)
dailySchedule := &Schedule{
ScheduleType: ScheduleTypeDaily,
Hour: 2,
Minute: 30,
TimeZone: "Europe/Berlin",
}
// 2026: Die Umstellung ist am Sonntag, 29. März.
dayBeforeSwitch := time.Date(2026, time.March, 28, 12, 0, 0, 0, berlinLocation)
nextRun, nextError := dailySchedule.NextRun(dayBeforeSwitch)
if nextError != nil {
testInstance.Fatalf("die Berechnung schlug fehl: %v", nextError)
}
// Der Lauf muss stattfinden — irgendwann am 29. März.
if nextRun.Day() != 29 || nextRun.Month() != time.March {
testInstance.Fatalf("der Lauf am Umstellungstag fiel aus; berechnet wurde %s", nextRun)
}
// 02:30 gibt es nicht; Go legt auf 03:30. Entscheidend ist, dass der Lauf
// überhaupt stattfindet und nicht übersprungen wird.
if nextRun.Hour() == 2 {
testInstance.Errorf("es wurde eine nicht existierende Uhrzeit berechnet: %s", nextRun)
}
// Und der Tag danach muss wieder normal sein.
dayAfterRun, dayAfterError := dailySchedule.NextRun(nextRun)
if dayAfterError != nil {
testInstance.Fatalf("die Berechnung schlug fehl: %v", dayAfterError)
}
if dayAfterRun.Hour() != 2 || dayAfterRun.Minute() != 30 {
testInstance.Errorf("nach der Umstellung wurde %s berechnet statt 02:30", dayAfterRun)
}
if dayAfterRun.Day() != 30 {
testInstance.Errorf("der Folgetag wurde als %d berechnet statt als 30.", dayAfterRun.Day())
}
}
// TestDailyScheduleRunsOnceOnFallBack prüft die doppelte Stunde.
//
// Im Herbst wird 02:00 bis 03:00 wiederholt. Ein Auftrag „täglich um 02:30"
// hätte an diesem Tag zwei Gelegenheiten. Er darf nur einmal laufen: zwei Läufe
// erzeugten zwei Backups mit demselben Zeitplan und verwirrten jede Kette.
func TestDailyScheduleRunsOnceOnFallBack(testInstance *testing.T) {
berlinLocation := mustLoadBerlin(testInstance)
dailySchedule := &Schedule{
ScheduleType: ScheduleTypeDaily,
Hour: 2,
Minute: 30,
TimeZone: "Europe/Berlin",
}
// 2026: Die Rückstellung ist am Sonntag, 25. Oktober.
dayBeforeSwitch := time.Date(2026, time.October, 24, 12, 0, 0, 0, berlinLocation)
firstRun, firstError := dailySchedule.NextRun(dayBeforeSwitch)
if firstError != nil {
testInstance.Fatalf("die Berechnung schlug fehl: %v", firstError)
}
if firstRun.Day() != 25 {
testInstance.Fatalf("der Lauf am Umstellungstag wurde auf %s gelegt", firstRun)
}
secondRun, secondError := dailySchedule.NextRun(firstRun)
if secondError != nil {
testInstance.Fatalf("die Berechnung schlug fehl: %v", secondError)
}
// Der Kern: Der nächste Lauf ist am Folgetag, nicht ein zweites Mal am
// selben Tag in der wiederholten Stunde.
if secondRun.Day() != 26 {
testInstance.Fatalf("der Auftrag lief am Umstellungstag zweimal; der zweite Lauf wurde auf %s gelegt", secondRun)
}
}
// TestNextRunIsAlwaysStrictlyLater prüft die Grundregel.
//
// Wäre der nächste Zeitpunkt gleich dem Bezugspunkt, liefe ein Auftrag
// unmittelbar nach seinem eigenen Ende erneut — eine Endlosschleife, die sich
// als Zeitplan ausgibt.
func TestNextRunIsAlwaysStrictlyLater(testInstance *testing.T) {
testSchedules := []*Schedule{
{ScheduleType: ScheduleTypeDaily, Hour: 2, Minute: 0},
{ScheduleType: ScheduleTypeHourly, Minute: 30},
{ScheduleType: ScheduleTypeWeekly, Weekdays: []time.Weekday{time.Monday}, Hour: 3},
{ScheduleType: ScheduleTypeMonthly, MonthDays: []int{1}, Hour: 4},
{ScheduleType: ScheduleTypeCron, CronExpression: "0 2 * * *"},
{ScheduleType: ScheduleTypeInterval, Interval: time.Hour},
}
// Ein Bezugspunkt, der auf jeden dieser Zeitpläne genau passt.
exactMatchTime := time.Date(2026, time.June, 1, 2, 0, 0, 0, time.UTC)
for _, testSchedule := range testSchedules {
nextRun, nextError := testSchedule.NextRun(exactMatchTime)
if nextError != nil {
testInstance.Fatalf("%s: die Berechnung schlug fehl: %v", testSchedule.ScheduleType, nextError)
}
if !nextRun.After(exactMatchTime) {
testInstance.Errorf("%s: der nächste Lauf %s liegt nicht nach dem Bezugspunkt %s",
testSchedule.ScheduleType, nextRun, exactMatchTime)
}
}
}
// TestMonthlyScheduleHandlesShortMonths prüft den 31. in kurzen Monaten.
func TestMonthlyScheduleHandlesShortMonths(testInstance *testing.T) {
monthlySchedule := &Schedule{
ScheduleType: ScheduleTypeMonthly,
MonthDays: []int{31},
Hour: 1,
}
// Vom 31. Januar aus ist der nächste 31. der im März — den 31. Februar gibt
// es nicht.
januaryReference := time.Date(2026, time.January, 31, 12, 0, 0, 0, time.UTC)
nextRun, nextError := monthlySchedule.NextRun(januaryReference)
if nextError != nil {
testInstance.Fatalf("die Berechnung schlug fehl: %v", nextError)
}
if nextRun.Month() != time.March || nextRun.Day() != 31 {
testInstance.Errorf("nach dem 31. Januar wurde %s berechnet, erwartet war der 31. März", nextRun)
}
}
// TestMonthlyScheduleSupportsLastDayOfMonth prüft den Monatsletzten.
//
// Wer eine Monatssicherung zum Monatsende will, meint den letzten Tag — nicht
// „fällt in vier Monaten des Jahres aus".
func TestMonthlyScheduleSupportsLastDayOfMonth(testInstance *testing.T) {
monthlySchedule := &Schedule{
ScheduleType: ScheduleTypeMonthly,
MonthDays: []int{lastDayOfMonth},
Hour: 23,
}
expectedLastDays := map[time.Month]int{
time.January: 31,
time.February: 28, // 2026 ist kein Schaltjahr
time.April: 30,
}
for referenceMonth, expectedDay := range expectedLastDays {
monthStart := time.Date(2026, referenceMonth, 1, 0, 0, 0, 0, time.UTC)
nextRun, nextError := monthlySchedule.NextRun(monthStart)
if nextError != nil {
testInstance.Fatalf("die Berechnung schlug fehl: %v", nextError)
}
if nextRun.Month() != referenceMonth || nextRun.Day() != expectedDay {
testInstance.Errorf("für %s wurde %s berechnet, erwartet war der %d.", referenceMonth, nextRun, expectedDay)
}
}
}
// TestMonthlyScheduleFindsLeapDay prüft den 29. Februar.
func TestMonthlyScheduleFindsLeapDay(testInstance *testing.T) {
leapDaySchedule := &Schedule{
ScheduleType: ScheduleTypeCron,
CronExpression: "0 3 29 2 *",
}
// 2026 und 2027 haben keinen 29. Februar; 2028 hat einen.
referenceTime := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
nextRun, nextError := leapDaySchedule.NextRun(referenceTime)
if nextError != nil {
testInstance.Fatalf("der Schalttag wurde nicht gefunden: %v", nextError)
}
if nextRun.Year() != 2028 || nextRun.Month() != time.February || nextRun.Day() != 29 {
testInstance.Errorf("es wurde %s berechnet, erwartet war der 29. Februar 2028", nextRun)
}
}
// TestWeeklyScheduleFindsNextMatchingDay prüft den Wochenplan.
func TestWeeklyScheduleFindsNextMatchingDay(testInstance *testing.T) {
weeklySchedule := &Schedule{
ScheduleType: ScheduleTypeWeekly,
Weekdays: []time.Weekday{time.Saturday, time.Sunday},
Hour: 22,
}
// Ein Mittwoch.
wednesdayReference := time.Date(2026, time.June, 3, 12, 0, 0, 0, time.UTC)
nextRun, nextError := weeklySchedule.NextRun(wednesdayReference)
if nextError != nil {
testInstance.Fatalf("die Berechnung schlug fehl: %v", nextError)
}
if nextRun.Weekday() != time.Saturday {
testInstance.Errorf("es wurde %s berechnet, erwartet war ein Samstag", nextRun.Weekday())
}
// Vom Samstag aus muss der Sonntag folgen, nicht der nächste Samstag.
followingRun, followingError := weeklySchedule.NextRun(nextRun)
if followingError != nil {
testInstance.Fatalf("die Berechnung schlug fehl: %v", followingError)
}
if followingRun.Weekday() != time.Sunday {
testInstance.Errorf("nach dem Samstag wurde %s berechnet", followingRun.Weekday())
}
}
// TestScheduleTimeZoneIsRespected prüft die Zeitzonenbindung.
//
// Ohne sie liefe dieselbe Konfiguration auf zwei Servern zu verschiedenen
// Zeiten — der häufigste Grund für ein Backup, das „mal um zwei und mal um
// vier" läuft.
func TestScheduleTimeZoneIsRespected(testInstance *testing.T) {
mustLoadBerlin(testInstance)
berlinSchedule := &Schedule{
ScheduleType: ScheduleTypeDaily,
Hour: 2,
TimeZone: "Europe/Berlin",
}
utcSchedule := &Schedule{
ScheduleType: ScheduleTypeDaily,
Hour: 2,
}
// Im Sommer liegt Berlin zwei Stunden vor UTC.
summerReference := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC)
berlinRun, _ := berlinSchedule.NextRun(summerReference)
utcRun, _ := utcSchedule.NextRun(summerReference)
if berlinRun.Equal(utcRun) {
testInstance.Fatal("die Zeitzone wurde nicht berücksichtigt: beide Zeitpläne ergaben denselben Zeitpunkt")
}
// 02:00 Berliner Zeit ist 00:00 UTC.
if berlinRun.UTC().Hour() != 0 {
testInstance.Errorf("02:00 Berliner Zeit wurde als %s UTC berechnet", berlinRun.UTC())
}
}
// TestInvalidSchedulesAreRejectedAtCreation prüft die Eingabeprüfung.
//
// Ein Zeitplan, dessen Fehler sich erst um 02:00 Uhr zeigt, fällt genau dann
// aus, wenn niemand hinsieht.
func TestInvalidSchedulesAreRejectedAtCreation(testInstance *testing.T) {
invalidSchedules := map[string]*Schedule{
"Stunde außerhalb": {ScheduleType: ScheduleTypeDaily, Hour: 24},
"Minute außerhalb": {ScheduleType: ScheduleTypeDaily, Minute: 60},
"Abstand zu klein": {ScheduleType: ScheduleTypeInterval, Interval: 10 * time.Second},
"Wochenplan ohne Tag": {ScheduleType: ScheduleTypeWeekly, Hour: 2},
"Monatsplan ohne Tag": {ScheduleType: ScheduleTypeMonthly, Hour: 2},
"Monatstag außerhalb": {ScheduleType: ScheduleTypeMonthly, MonthDays: []int{32}},
"unbekannte Zeitzone": {ScheduleType: ScheduleTypeDaily, TimeZone: "Mars/Olympus_Mons"},
"unbekannte Art": {ScheduleType: "gelegentlich"},
"cron mit zu wenig Feldern": {ScheduleType: ScheduleTypeCron, CronExpression: "0 2 *"},
"cron mit Unsinn": {ScheduleType: ScheduleTypeCron, CronExpression: "99 2 * * *"},
}
for caseName, invalidSchedule := range invalidSchedules {
if validationError := invalidSchedule.Validate(); validationError == nil {
testInstance.Errorf("%s: der Zeitplan wurde angenommen", caseName)
}
}
}
// TestManualScheduleHasNoNextRun prüft den Zeitplan ohne Automatik.
func TestManualScheduleHasNoNextRun(testInstance *testing.T) {
manualSchedule := &Schedule{ScheduleType: ScheduleTypeManual}
if validationError := manualSchedule.Validate(); validationError != nil {
testInstance.Fatalf("ein Zeitplan auf Anforderung muss gültig sein: %v", validationError)
}
if _, nextError := manualSchedule.NextRun(time.Now()); !errors.Is(nextError, ErrNoNextRun) {
testInstance.Fatalf("ein Zeitplan auf Anforderung darf keinen nächsten Lauf haben: %v", nextError)
}
}
// TestCronDayOfMonthAndWeekdayUseOrLogic prüft die Cron-Sonderregel.
//
// Sind beide Tagesfelder eingeschränkt, gilt ODER statt UND. "0 0 13 * 5"
// bedeutet „am 13. oder freitags". Wer das als UND umsetzt, baut einen
// Zeitplan, der fast nie läuft — und es fällt erst nach Monaten auf.
func TestCronDayOfMonthAndWeekdayUseOrLogic(testInstance *testing.T) {
orSchedule := &Schedule{ScheduleType: ScheduleTypeCron, CronExpression: "0 0 13 * 5"}
parsedExpression, parseError := parseCronExpression(orSchedule.CronExpression)
if parseError != nil {
testInstance.Fatalf("der Ausdruck wurde abgelehnt: %v", parseError)
}
// Der 13. Juni 2026 ist ein Samstag: passt über den Monatstag.
thirteenthSaturday := time.Date(2026, time.June, 13, 0, 0, 0, 0, time.UTC)
if !parsedExpression.matches(thirteenthSaturday) {
testInstance.Error("der 13. wurde nicht erkannt, obwohl das Tagesfeld passt")
}
// Freitag, der 5. Juni 2026: passt über den Wochentag.
ordinaryFriday := time.Date(2026, time.June, 5, 0, 0, 0, 0, time.UTC)
if !parsedExpression.matches(ordinaryFriday) {
testInstance.Error("ein Freitag wurde nicht erkannt, obwohl das Wochentagsfeld passt")
}
// Ein gewöhnlicher Dienstag, der nicht der 13. ist: passt nicht.
ordinaryTuesday := time.Date(2026, time.June, 9, 0, 0, 0, 0, time.UTC)
if parsedExpression.matches(ordinaryTuesday) {
testInstance.Error("ein beliebiger Tag wurde angenommen")
}
}
// TestCronUsesAndLogicWhenOnlyOneFieldRestricted prüft den Normalfall.
func TestCronUsesAndLogicWhenOnlyOneFieldRestricted(testInstance *testing.T) {
// Nur der Wochentag ist eingeschränkt: "montags um 3".
mondaysOnly, parseError := parseCronExpression("0 3 * * 1")
if parseError != nil {
testInstance.Fatalf("der Ausdruck wurde abgelehnt: %v", parseError)
}
monday := time.Date(2026, time.June, 1, 3, 0, 0, 0, time.UTC)
if !mondaysOnly.matches(monday) {
testInstance.Error("ein Montag wurde nicht erkannt")
}
tuesday := time.Date(2026, time.June, 2, 3, 0, 0, 0, time.UTC)
if mondaysOnly.matches(tuesday) {
testInstance.Error("ein Dienstag wurde angenommen, obwohl nur Montage gelten")
}
}
// TestCronParsesStepsRangesAndNames prüft die Schreibweisen.
func TestCronParsesStepsRangesAndNames(testInstance *testing.T) {
stepExpression, parseError := parseCronExpression("*/15 9-17 * * mon-fri")
if parseError != nil {
testInstance.Fatalf("der Ausdruck wurde abgelehnt: %v", parseError)
}
// Alle 15 Minuten zwischen 9 und 17 Uhr an Werktagen.
workdayMorning := time.Date(2026, time.June, 3, 9, 15, 0, 0, time.UTC)
if !stepExpression.matches(workdayMorning) {
testInstance.Error("9:15 an einem Mittwoch wurde nicht erkannt")
}
if stepExpression.matches(time.Date(2026, time.June, 3, 9, 20, 0, 0, time.UTC)) {
testInstance.Error("9:20 wurde angenommen, obwohl nur Viertelstunden gelten")
}
if stepExpression.matches(time.Date(2026, time.June, 6, 9, 15, 0, 0, time.UTC)) {
testInstance.Error("ein Samstag wurde angenommen, obwohl nur Werktage gelten")
}
if stepExpression.matches(time.Date(2026, time.June, 3, 18, 15, 0, 0, time.UTC)) {
testInstance.Error("18:15 wurde angenommen, obwohl der Bereich bei 17 endet")
}
}
// TestCronSundayAcceptsBothSevenAndZero prüft die doppelte Schreibweise.
func TestCronSundayAcceptsBothSevenAndZero(testInstance *testing.T) {
sunday := time.Date(2026, time.June, 7, 4, 0, 0, 0, time.UTC)
for _, sundayExpression := range []string{"0 4 * * 0", "0 4 * * 7", "0 4 * * sun"} {
parsedExpression, parseError := parseCronExpression(sundayExpression)
if parseError != nil {
testInstance.Fatalf("%q wurde abgelehnt: %v", sundayExpression, parseError)
}
if !parsedExpression.matches(sunday) {
testInstance.Errorf("%q erkannte den Sonntag nicht", sundayExpression)
}
}
}
// TestCronPredefinedExpressions prüft die Kurzformen.
func TestCronPredefinedExpressions(testInstance *testing.T) {
dailySchedule := &Schedule{ScheduleType: ScheduleTypeCron, CronExpression: "@daily"}
referenceTime := time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC)
nextRun, nextError := dailySchedule.NextRun(referenceTime)
if nextError != nil {
testInstance.Fatalf("@daily wurde abgelehnt: %v", nextError)
}
if nextRun.Hour() != 0 || nextRun.Day() != 2 {
testInstance.Errorf("@daily ergab %s, erwartet war der 2. Juni um 00:00", nextRun)
}
}
// TestDescribeProducesReadableText prüft die Beschreibung für die Oberfläche.
func TestDescribeProducesReadableText(testInstance *testing.T) {
describedSchedules := map[string]*Schedule{
"täglich um 02:00 Uhr (Europe/Berlin)": {
ScheduleType: ScheduleTypeDaily, Hour: 2, TimeZone: "Europe/Berlin",
},
"alle 6 Stunden": {
ScheduleType: ScheduleTypeInterval, Interval: 6 * time.Hour,
},
"nur auf Anforderung": {ScheduleType: ScheduleTypeManual},
}
for expectedText, testSchedule := range describedSchedules {
if describedText := testSchedule.Describe(); describedText != expectedText {
testInstance.Errorf("beschrieben wurde %q, erwartet war %q", describedText, expectedText)
}
}
}