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

246 lines
8.8 KiB
Go

package scheduler
import (
"testing"
"time"
)
// TestBlackoutWindowBlocksAndDefersRun prüft das Sperrfenster.
//
// Ein durch ein Fenster verhinderter Lauf wird verschoben, nicht übergangen.
// Ihn ausfallen zu lassen erzeugte eine Lücke in der Sicherungskette, von der
// niemand erführe: Dass ein Lauf verspätet ist, sieht man; dass er fehlt, nicht.
func TestBlackoutWindowBlocksAndDefersRun(testInstance *testing.T) {
windowSet, setError := NewWindowSet([]MaintenanceWindow{
{
Identifier: "inventur",
Name: "Jahresinventur",
WindowKind: WindowKindBlackout,
StartsAt: time.Date(2026, time.December, 27, 0, 0, 0, 0, time.UTC),
EndsAt: time.Date(2026, time.December, 28, 6, 0, 0, 0, time.UTC),
},
})
if setError != nil {
testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError)
}
insideWindow := time.Date(2026, time.December, 27, 2, 0, 0, 0, time.UTC)
isBlocked, blockReason := windowSet.IsBlocked(insideWindow, "taeglich")
if !isBlocked {
testInstance.Fatal("ein Zeitpunkt im Sperrfenster wurde freigegeben")
}
if blockReason.WindowName != "Jahresinventur" {
testInstance.Errorf("die Begründung nennt das Fenster nicht: %+v", blockReason)
}
// Der Lauf muss nachgeholt werden — unmittelbar nach dem Fenster.
deferredTime, deferError := windowSet.NextAllowedTime(insideWindow, "taeglich")
if deferError != nil {
testInstance.Fatalf("es wurde kein Ausweichzeitpunkt gefunden: %v", deferError)
}
if deferredTime.Before(time.Date(2026, time.December, 28, 6, 0, 0, 0, time.UTC)) {
testInstance.Errorf("der Ausweichzeitpunkt %s liegt noch im Fenster", deferredTime)
}
}
// TestAllowedWindowBlocksOutsideItself prüft die Umkehrung.
func TestAllowedWindowBlocksOutsideItself(testInstance *testing.T) {
windowSet, setError := NewWindowSet([]MaintenanceWindow{
{
Name: "Nachtfenster",
WindowKind: WindowKindAllowed,
Recurring: &RecurringWindow{
StartHour: 22,
Duration: 8 * time.Hour,
},
},
})
if setError != nil {
testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError)
}
// Mitten am Tag: außerhalb des Erlaubnisfensters.
middayTime := time.Date(2026, time.June, 1, 14, 0, 0, 0, time.UTC)
if isBlocked, _ := windowSet.IsBlocked(middayTime, "taeglich"); !isBlocked {
testInstance.Fatal("ein Zeitpunkt außerhalb des Erlaubnisfensters wurde freigegeben")
}
// In der Nacht: zugelassen.
nightTime := time.Date(2026, time.June, 1, 23, 0, 0, 0, time.UTC)
if isBlocked, blockReason := windowSet.IsBlocked(nightTime, "taeglich"); isBlocked {
testInstance.Fatalf("ein Zeitpunkt im Erlaubnisfenster wurde gesperrt: %+v", blockReason)
}
}
// TestRecurringWindowSpansMidnight ist der Test für den häufigsten Denkfehler.
//
// Ein Fenster, das samstags um 22:00 beginnt und zwölf Stunden dauert, reicht
// bis Sonntag 10:00. Wer nur den Tag des Zeitpunkts prüft, hielte Sonntag 09:00
// für frei — und liesse den Auftrag mitten in die Wartung laufen.
func TestRecurringWindowSpansMidnight(testInstance *testing.T) {
windowSet, setError := NewWindowSet([]MaintenanceWindow{
{
Name: "Wochenendwartung",
WindowKind: WindowKindBlackout,
Recurring: &RecurringWindow{
Weekdays: []time.Weekday{time.Saturday},
StartHour: 22,
Duration: 12 * time.Hour,
},
},
})
if setError != nil {
testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError)
}
// Samstag, 6. Juni 2026, 23:00 — im Fenster.
saturdayNight := time.Date(2026, time.June, 6, 23, 0, 0, 0, time.UTC)
if isBlocked, _ := windowSet.IsBlocked(saturdayNight, "taeglich"); !isBlocked {
testInstance.Error("Samstag 23:00 wurde freigegeben, obwohl das Fenster um 22:00 begann")
}
// Sonntag 09:00 — noch im Fenster, obwohl es ein anderer Tag ist.
sundayMorning := time.Date(2026, time.June, 7, 9, 0, 0, 0, time.UTC)
if isBlocked, _ := windowSet.IsBlocked(sundayMorning, "taeglich"); !isBlocked {
testInstance.Fatal("Sonntag 09:00 wurde freigegeben, obwohl das Samstagsfenster bis 10:00 reicht")
}
// Sonntag 11:00 — nach dem Fenster.
sundayLate := time.Date(2026, time.June, 7, 11, 0, 0, 0, time.UTC)
if isBlocked, _ := windowSet.IsBlocked(sundayLate, "taeglich"); isBlocked {
testInstance.Error("Sonntag 11:00 wurde gesperrt, obwohl das Fenster um 10:00 endete")
}
}
// TestWindowAppliesOnlyToNamedJobs prüft die Einschränkung.
func TestWindowAppliesOnlyToNamedJobs(testInstance *testing.T) {
windowSet, _ := NewWindowSet([]MaintenanceWindow{
{
Name: "Nur für die Datenbank",
WindowKind: WindowKindBlackout,
StartsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC),
EndsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC),
AppliesToJobIDs: []string{"datenbank"},
},
})
insideWindow := time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC)
if isBlocked, _ := windowSet.IsBlocked(insideWindow, "datenbank"); !isBlocked {
testInstance.Error("der benannte Auftrag wurde nicht gesperrt")
}
if isBlocked, _ := windowSet.IsBlocked(insideWindow, "dateiserver"); isBlocked {
testInstance.Error("ein nicht benannter Auftrag wurde mitgesperrt")
}
}
// TestAllowedWindowForOtherJobDoesNotBlockEveryone ist der Test gegen einen
// gefährlichen Kurzschluss.
//
// Gäbe es irgendwo ein Erlaubnisfenster und würde die Regel auf alle Aufträge
// angewandt, fielen sämtliche Sicherungen aus, die nicht gemeint waren.
func TestAllowedWindowForOtherJobDoesNotBlockEveryone(testInstance *testing.T) {
windowSet, _ := NewWindowSet([]MaintenanceWindow{
{
Name: "Nur nachts für die Datenbank",
WindowKind: WindowKindAllowed,
AppliesToJobIDs: []string{"datenbank"},
Recurring: &RecurringWindow{
StartHour: 2,
Duration: 2 * time.Hour,
},
},
})
middayTime := time.Date(2026, time.June, 1, 14, 0, 0, 0, time.UTC)
// Die Datenbank darf mittags nicht.
if isBlocked, _ := windowSet.IsBlocked(middayTime, "datenbank"); !isBlocked {
testInstance.Error("die Datenbank lief außerhalb ihres Erlaubnisfensters")
}
// Alle anderen sind davon unberührt.
if isBlocked, blockReason := windowSet.IsBlocked(middayTime, "dateiserver"); isBlocked {
testInstance.Fatalf("ein fremdes Erlaubnisfenster sperrte einen unbeteiligten Auftrag: %+v", blockReason)
}
}
// TestNextAllowedTimeFailsOnPermanentBlackout prüft die Fehlkonfiguration.
//
// Ein Sperrfenster ohne absehbares Ende ist fast immer ein Fehler. Er wird als
// solcher gemeldet statt als stiller Ausfall.
func TestNextAllowedTimeFailsOnPermanentBlackout(testInstance *testing.T) {
windowSet, _ := NewWindowSet([]MaintenanceWindow{
{
Name: "Dauersperre",
WindowKind: WindowKindBlackout,
Recurring: &RecurringWindow{
StartHour: 0,
// Ein Fenster über die volle Woche sperrt jeden Zeitpunkt.
Duration: 7 * 24 * time.Hour,
},
},
})
_, searchError := windowSet.NextAllowedTime(time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC), "taeglich")
if searchError == nil {
testInstance.Fatal("eine Dauersperre wurde nicht als Fehlkonfiguration gemeldet")
}
}
// TestWindowValidationRejectsBrokenDefinitions prüft die Eingabeprüfung.
func TestWindowValidationRejectsBrokenDefinitions(testInstance *testing.T) {
brokenWindows := map[string]MaintenanceWindow{
"unbekannte Wirkung": {WindowKind: "vielleicht", StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour)},
"Ende vor Anfang": {
WindowKind: WindowKindBlackout,
StartsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC),
EndsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC),
},
"einmalig ohne Zeiten": {WindowKind: WindowKindBlackout},
"wiederkehrend ohne Länge": {
WindowKind: WindowKindBlackout,
Recurring: &RecurringWindow{StartHour: 2},
},
"wiederkehrend zu lang": {
WindowKind: WindowKindBlackout,
Recurring: &RecurringWindow{StartHour: 2, Duration: 8 * 24 * time.Hour},
},
"unbekannte Zeitzone": {
WindowKind: WindowKindBlackout,
Recurring: &RecurringWindow{StartHour: 2, Duration: time.Hour, TimeZone: "Mars/Olympus_Mons"},
},
}
for caseName, brokenWindow := range brokenWindows {
if validationError := brokenWindow.Validate(); validationError == nil {
testInstance.Errorf("%s: das Fenster wurde angenommen", caseName)
}
}
}
// TestActiveWindowsExplainsWhyJobWaits prüft die Auskunft für die Oberfläche.
func TestActiveWindowsExplainsWhyJobWaits(testInstance *testing.T) {
windowSet, _ := NewWindowSet([]MaintenanceWindow{
{
Name: "Monatsabschluss",
WindowKind: WindowKindBlackout,
StartsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC),
EndsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC),
},
})
activeWindows := windowSet.ActiveWindows(time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC), "taeglich")
if len(activeWindows) != 1 || activeWindows[0].Name != "Monatsabschluss" {
testInstance.Fatalf("das wirksame Fenster wurde nicht ausgewiesen: %+v", activeWindows)
}
}