syncova-backup/packages/jobs/model_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

202 lines
7.5 KiB
Go

package jobs
import (
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/scheduler"
)
// buildValidJob liefert einen brauchbaren Auftrag als Ausgangspunkt.
func buildValidJob() Job {
return Job{
Name: "Nächtliche Sicherung",
Status: JobStatusActive,
Priority: scheduler.PriorityNormal,
Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 2},
RepositoryID: uuid.New(),
Sources: []JobSource{
{SourceType: SourceTypeFilesystem, SourceID: "/daten", SourceName: "Dateiserver"},
},
MaximumConcurrency: 1,
RetryPolicy: scheduler.DefaultRetryPolicy(),
}
}
// TestValidJobIsAccepted prüft den Normalfall.
func TestValidJobIsAccepted(testInstance *testing.T) {
validJob := buildValidJob()
if validationError := validJob.Validate(); validationError != nil {
testInstance.Fatalf("ein gültiger Auftrag wurde abgelehnt: %v", validationError)
}
}
// TestJobWithoutSourceIsRejected ist der wichtigste Prüftest.
//
// Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern — die
// gefährlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht.
func TestJobWithoutSourceIsRejected(testInstance *testing.T) {
jobWithoutSource := buildValidJob()
jobWithoutSource.Sources = nil
validationError := jobWithoutSource.Validate()
if !errors.Is(validationError, ErrInvalidJob) {
testInstance.Fatalf("ein Auftrag ohne Quelle wurde angenommen: %v", validationError)
}
if !strings.Contains(validationError.Error(), "quelle") {
testInstance.Errorf("die Fehlermeldung nennt die Ursache nicht: %v", validationError)
}
}
// TestDuplicateSourceIsRejected prüft die doppelte Quelle.
//
// Sie verdoppelte die Datenmenge, ohne mehr zu sichern.
func TestDuplicateSourceIsRejected(testInstance *testing.T) {
jobWithDuplicate := buildValidJob()
jobWithDuplicate.Sources = append(jobWithDuplicate.Sources, JobSource{
SourceType: SourceTypeFilesystem,
SourceID: "/daten",
})
if validationError := jobWithDuplicate.Validate(); validationError == nil {
testInstance.Fatal("eine doppelt aufgeführte Quelle wurde angenommen")
}
// Dieselbe Kennung bei anderer Quellart ist dagegen zulässig.
jobWithDistinctTypes := buildValidJob()
jobWithDistinctTypes.Sources = append(jobWithDistinctTypes.Sources, JobSource{
SourceType: SourceTypeLinuxSystem,
SourceID: "/daten",
})
if validationError := jobWithDistinctTypes.Validate(); validationError != nil {
testInstance.Errorf("verschiedene Quellarten mit gleicher Kennung wurden abgelehnt: %v", validationError)
}
}
// TestRecoveryPointObjectiveMustMatchSchedule ist der Test, der eine stille
// Fehlannahme verhindert.
//
// Ein RPO, den der Zeitplan nicht einhalten kann, ist keine Kleinigkeit: Der
// Betreiber glaubt, höchstens vier Stunden zu verlieren, während der Auftrag
// nur täglich läuft. Das gehört beim Anlegen gesagt, nicht nach dem Ausfall.
func TestRecoveryPointObjectiveMustMatchSchedule(testInstance *testing.T) {
impossibleJob := buildValidJob()
impossibleJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 2}
impossibleJob.RecoveryPointObjective = 4 * time.Hour
validationError := impossibleJob.Validate()
if validationError == nil {
testInstance.Fatal("ein täglicher Zeitplan mit 4-Stunden-RPO wurde angenommen")
}
if !strings.Contains(validationError.Error(), "wiederherstellungspunkt") {
testInstance.Errorf("die Fehlermeldung erklärt den Widerspruch nicht: %v", validationError)
}
// Ein stündlicher Plan hält vier Stunden dagegen mühelos ein.
feasibleJob := buildValidJob()
feasibleJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeHourly}
feasibleJob.RecoveryPointObjective = 4 * time.Hour
if validationError := feasibleJob.Validate(); validationError != nil {
testInstance.Errorf("ein einhaltbarer Wiederherstellungspunkt wurde abgelehnt: %v", validationError)
}
}
// TestManualScheduleCannotPromiseRecoveryPoint prüft den Sonderfall.
func TestManualScheduleCannotPromiseRecoveryPoint(testInstance *testing.T) {
manualJob := buildValidJob()
manualJob.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual}
manualJob.RecoveryPointObjective = 24 * time.Hour
if validationError := manualJob.Validate(); validationError == nil {
testInstance.Fatal("ein Zeitplan auf Anforderung versprach einen Wiederherstellungspunkt")
}
}
// TestInvalidJobsAreRejected prüft die übrigen Prüfungen.
func TestInvalidJobsAreRejected(testInstance *testing.T) {
invalidJobs := map[string]func(*Job){
"ohne Namen": func(job *Job) { job.Name = " " },
"Name zu lang": func(job *Job) { job.Name = strings.Repeat("x", 201) },
"ohne Repository": func(job *Job) { job.RepositoryID = uuid.Nil },
"unbekannte Quellart": func(job *Job) { job.Sources[0].SourceType = "vmware_vm" },
"Quelle ohne Kennung": func(job *Job) { job.Sources[0].SourceID = "" },
"unbekannte Dringlichkeit": func(job *Job) {
job.Priority = "sehr-dringend"
},
"kaputter Zeitplan": func(job *Job) {
job.Schedule = scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeDaily, Hour: 99}
},
"keine Nebenläufigkeit": func(job *Job) { job.MaximumConcurrency = 0 },
"negative Bandbreite": func(job *Job) { job.BandwidthLimitBytesPerSecond = -1 },
"kaputte Wiederholung": func(job *Job) {
job.RetryPolicy = scheduler.RetryPolicy{MaximumAttempts: 0}
},
}
for caseName, applyDefect := range invalidJobs {
defectiveJob := buildValidJob()
applyDefect(&defectiveJob)
if validationError := defectiveJob.Validate(); validationError == nil {
testInstance.Errorf("%s: der Auftrag wurde angenommen", caseName)
}
}
}
// TestSelfDependencyIsRejected prüft die Selbstabhängigkeit.
func TestSelfDependencyIsRejected(testInstance *testing.T) {
selfDependentJob := buildValidJob()
selfDependentJob.ID = uuid.New()
selfDependentJob.DependsOnJobIDs = []uuid.UUID{selfDependentJob.ID}
if validationError := selfDependentJob.Validate(); validationError == nil {
testInstance.Fatal("ein Auftrag, der von sich selbst abhängt, wurde angenommen")
}
}
// TestSourceReferenceIsUniquePerSource prüft die Kettenkennung.
//
// Ein Auftrag mit mehreren Quellen erzeugt je Quelle eine eigene Kette: Wären
// sie in einer, ließe sich eine einzelne Quelle nicht mehr eigenständig
// wiederherstellen.
func TestSourceReferenceIsUniquePerSource(testInstance *testing.T) {
firstSource := JobSource{SourceType: SourceTypeProxmoxVM, SourceID: "qemu/100"}
secondSource := JobSource{SourceType: SourceTypeProxmoxVM, SourceID: "qemu/101"}
sameIDOtherType := JobSource{SourceType: SourceTypeFilesystem, SourceID: "qemu/100"}
if firstSource.SourceReference() == secondSource.SourceReference() {
testInstance.Error("zwei verschiedene Gäste ergaben dieselbe Kettenkennung")
}
if firstSource.SourceReference() == sameIDOtherType.SourceReference() {
testInstance.Error("gleiche Kennung bei anderer Quellart ergab dieselbe Kettenkennung")
}
}
// TestIsRunnableOnlyWhenActive prüft den Ausführungszustand.
func TestIsRunnableOnlyWhenActive(testInstance *testing.T) {
notRunnableStatuses := []JobStatus{JobStatusPaused, JobStatusDisabled, JobStatusError}
for _, jobStatus := range notRunnableStatuses {
testJob := buildValidJob()
testJob.Status = jobStatus
if testJob.IsRunnable() {
testInstance.Errorf("ein Auftrag im Zustand %q wurde als ausführbar gemeldet", jobStatus)
}
}
activeJob := buildValidJob()
if !activeJob.IsRunnable() {
testInstance.Error("ein aktiver Auftrag wurde als nicht ausführbar gemeldet")
}
}