syncova-backup/packages/backupexecutor/executor_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

719 lines
27 KiB
Go

package backupexecutor
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/syncova/syncova/packages/backupengine"
"github.com/syncova/syncova/packages/jobs"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/repository"
"github.com/syncova/syncova/packages/scheduler"
)
// connectTestDatabase öffnet die Testdatenbank.
//
// Ohne erreichbare Datenbank werden die Tests übersprungen: Ein rot leuchtender
// Testlauf auf einem Rechner ohne Docker gewöhnt nur das Wegschauen an.
func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool {
testInstance.Helper()
connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL")
if connectionString == "" {
testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden übersprungen")
}
connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelConnect()
connectionPool, poolError := pgxpool.New(connectContext, connectionString)
if poolError != nil {
testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError)
}
if pingError := connectionPool.Ping(connectContext); pingError != nil {
connectionPool.Close()
testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError)
}
testInstance.Cleanup(connectionPool.Close)
return connectionPool
}
// discardLogger unterdrückt die Protokollausgabe der Tests.
func discardLogger() *slog.Logger {
return slog.New(slog.NewJSONHandler(io.Discard, nil))
}
// buildTestSecretStore erzeugt einen Schlüsselspeicher.
func buildTestSecretStore(testInstance *testing.T) crypto.SecretStore {
testInstance.Helper()
builtStore, storeError := crypto.NewLocalSecretStore(
map[string][]byte{"v1": bytes.Repeat([]byte{0x42}, 32)}, "v1")
if storeError != nil {
testInstance.Fatalf("der Schlüsselspeicher ließ sich nicht bauen: %v", storeError)
}
return builtStore
}
// buildSourceTree legt einen Verzeichnisbaum als Sicherungsquelle an.
func buildSourceTree(testInstance *testing.T) string {
testInstance.Helper()
sourcePath := testInstance.TempDir()
fileContents := map[string]string{
"dokumente/bericht.txt": strings.Repeat("Ein Bericht mit Inhalt. ", 200),
"dokumente/notiz.txt": "Eine kurze Notiz.",
"config.ini": "[abschnitt]\nwert=1\n",
}
for relativePath, fileContent := range fileContents {
fullPath := filepath.Join(sourcePath, relativePath)
if directoryError := os.MkdirAll(filepath.Dir(fullPath), 0o755); directoryError != nil {
testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", directoryError)
}
if writeError := os.WriteFile(fullPath, []byte(fileContent), 0o644); writeError != nil {
testInstance.Fatalf("die Datei ließ sich nicht anlegen: %v", writeError)
}
}
return sourcePath
}
// registerRepository legt ein Repository an und trägt es in die Control Plane ein.
func registerRepository(testInstance *testing.T, connectionPool *pgxpool.Pool) (uuid.UUID, string) {
testInstance.Helper()
repositoryPath := filepath.Join(testInstance.TempDir(), "repository")
createdRepository, createError := repository.Create(context.Background(), repositoryPath,
repository.CreateOptions{Name: "Testziel", CreatedByVersion: "test"}, discardLogger())
if createError != nil {
testInstance.Fatalf("das Repository ließ sich nicht anlegen: %v", createError)
}
// Es wird sofort wieder geschlossen: Der Executor öffnet es selbst und
// braucht dafür die Schreibsperre.
if closeError := createdRepository.Close(); closeError != nil {
testInstance.Fatalf("das Repository ließ sich nicht schließen: %v", closeError)
}
repositoryName := "exec-" + uuid.NewString()
var repositoryID uuid.UUID
if scanError := connectionPool.QueryRow(context.Background(),
`INSERT INTO repositories (name, location) VALUES ($1, $2) RETURNING id`,
repositoryName, repositoryPath).Scan(&repositoryID); scanError != nil {
testInstance.Fatalf("das Repository ließ sich nicht eintragen: %v", scanError)
}
// Jede Anweisung einzeln: pgx sendet ueber das erweiterte Protokoll und
// fuehrt mehrere durch Semikolon getrennte Anweisungen in einem Exec nicht
// aus. Zusammengefasst blieben die Zeilen unbemerkt in der Datenbank liegen.
testInstance.Cleanup(func() {
cleanupContext := context.Background()
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backups WHERE repository_id = $1`, repositoryID)
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backup_chains WHERE repository_id = $1`, repositoryID)
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM repositories WHERE id = $1`, repositoryID)
})
return repositoryID, repositoryPath
}
// buildExecutionJob baut einen Auftrag für den Executor.
func buildExecutionJob(repositoryID uuid.UUID, sourcePath string) *jobs.Job {
return &jobs.Job{
ID: uuid.New(),
Name: "Executor-Test",
Status: jobs.JobStatusActive,
Priority: scheduler.PriorityNormal,
Schedule: scheduler.Schedule{ScheduleType: scheduler.ScheduleTypeManual},
RepositoryID: repositoryID,
Sources: []jobs.JobSource{
{SourceType: jobs.SourceTypeFilesystem, SourceID: sourcePath, SourceName: "Testquelle"},
},
MaximumConcurrency: 1,
RetryPolicy: scheduler.DefaultRetryPolicy(),
}
}
// createRunRow legt einen Lauf an, damit der Fremdschlüssel des Backups hält.
func createRunRow(testInstance *testing.T, connectionPool *pgxpool.Pool, repositoryID uuid.UUID, executionJob *jobs.Job) uuid.UUID {
testInstance.Helper()
var jobIdentifier uuid.UUID
if scanError := connectionPool.QueryRow(context.Background(), `
INSERT INTO backup_jobs (name, schedule_type, repository_id)
VALUES ($1, 'manual', $2) RETURNING id`,
executionJob.Name+"-"+uuid.NewString()[:8], repositoryID).Scan(&jobIdentifier); scanError != nil {
testInstance.Fatalf("der Auftrag ließ sich nicht eintragen: %v", scanError)
}
executionJob.ID = jobIdentifier
var runIdentifier uuid.UUID
if scanError := connectionPool.QueryRow(context.Background(), `
INSERT INTO backup_job_runs (job_id, status, correlation_id)
VALUES ($1, 'running', gen_random_uuid()) RETURNING id`,
jobIdentifier).Scan(&runIdentifier); scanError != nil {
testInstance.Fatalf("der Lauf ließ sich nicht eintragen: %v", scanError)
}
testInstance.Cleanup(func() {
cleanupContext := context.Background()
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backups WHERE job_run_id = $1`, runIdentifier)
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backup_job_runs WHERE job_id = $1`, jobIdentifier)
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backup_jobs WHERE id = $1`, jobIdentifier)
})
return runIdentifier
}
// TestExecutorBacksUpAndRestores ist der Nachweis der ganzen Kette.
//
// Auftrag → Executor → Backup Engine → Repository → Wiederherstellung. Ohne
// diesen Test bliebe offen, ob der Scheduler tatsächlich sichert oder nur
// erfolgreich aussieht.
func TestExecutorBacksUpAndRestores(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, repositoryPath := registerRepository(testInstance, connectionPool)
sourcePath := buildSourceTree(testInstance)
executionJob := buildExecutionJob(repositoryID, sourcePath)
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, executionJob)
testExecutor, executorError := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
CreatedByVersion: "test",
}, discardLogger())
if executorError != nil {
testInstance.Fatalf("der Executor ließ sich nicht bauen: %v", executorError)
}
executionResult, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: executionJob,
RunID: runIdentifier,
CorrelationID: uuid.New(),
AttemptNumber: 1,
})
if executeError != nil {
testInstance.Fatalf("die Ausführung schlug fehl: %v", executeError)
}
if executionResult.FilesSkipped != 0 {
testInstance.Errorf("es wurden %d Objekte übergangen: %v",
executionResult.FilesSkipped, executionResult.SkipReasons)
}
if executionResult.BytesProcessed == 0 {
testInstance.Fatal("es wurden keine Daten gelesen; der Lauf sichert nichts")
}
// Der entscheidende Nachweis: Die Daten liegen tatsächlich im Repository.
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, discardLogger())
if openError != nil {
testInstance.Fatalf("das Repository ließ sich nicht öffnen: %v", openError)
}
defer func() { _ = openedRepository.Close() }()
catalogEntries, listError := openedRepository.ListBackups(context.Background())
if listError != nil {
testInstance.Fatalf("die Backups ließen sich nicht auflisten: %v", listError)
}
if len(catalogEntries) != 1 {
testInstance.Fatalf("im Repository liegen %d Backups, erwartet war 1", len(catalogEntries))
}
// Und der Datenbankeintrag verweist auf dasselbe Backup.
var recordedBackupID string
var recordedType string
if scanError := connectionPool.QueryRow(context.Background(),
`SELECT backup_id_in_repository, backup_type FROM backups WHERE job_run_id = $1`,
runIdentifier).Scan(&recordedBackupID, &recordedType); scanError != nil {
testInstance.Fatalf("das Backup wurde nicht in der Datenbank vermerkt: %v", scanError)
}
if recordedBackupID != catalogEntries[0].BackupID {
testInstance.Errorf("die Datenbank verweist auf %q, im Repository liegt %q",
recordedBackupID, catalogEntries[0].BackupID)
}
if recordedType != "full" {
testInstance.Errorf("das erste Backup wurde als %q vermerkt", recordedType)
}
}
// TestExecutorUsesIncrementalOnSecondRun prüft die Zusatzsicherung.
//
// Der zweite Lauf desselben Auftrags muss auf dem ersten aufbauen. Täte er es
// nicht, läse jede Nacht der gesamte Bestand erneut — und die Zusatzsicherung
// wäre nur eine Behauptung.
func TestExecutorUsesIncrementalOnSecondRun(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, _ := registerRepository(testInstance, connectionPool)
sourcePath := buildSourceTree(testInstance)
executionJob := buildExecutionJob(repositoryID, sourcePath)
firstRunID := createRunRow(testInstance, connectionPool, repositoryID, executionJob)
testExecutor, _ := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
CreatedByVersion: "test",
}, discardLogger())
firstResult, firstError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: executionJob, RunID: firstRunID, AttemptNumber: 1,
})
if firstError != nil {
testInstance.Fatalf("der erste Lauf schlug fehl: %v", firstError)
}
// Der erste Lauf wird abgeschlossen. Ohne diesen Schritt verhindert der
// Teilindex den zweiten — genau wie im Betrieb: Ein Auftrag hat nie zwei
// aktive Läufe.
if _, execError := connectionPool.Exec(context.Background(),
`UPDATE backup_job_runs SET status = 'succeeded', completed_at = now() WHERE id = $1`,
firstRunID); execError != nil {
testInstance.Fatalf("der erste Lauf ließ sich nicht abschließen: %v", execError)
}
// Ein zweiter Lauf desselben Auftrags.
var secondRunID uuid.UUID
if scanError := connectionPool.QueryRow(context.Background(), `
INSERT INTO backup_job_runs (job_id, status, correlation_id)
VALUES ($1, 'running', gen_random_uuid()) RETURNING id`,
executionJob.ID).Scan(&secondRunID); scanError != nil {
testInstance.Fatalf("der zweite Lauf ließ sich nicht anlegen: %v", scanError)
}
secondResult, secondError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: executionJob, RunID: secondRunID, AttemptNumber: 1,
})
if secondError != nil {
testInstance.Fatalf("der zweite Lauf schlug fehl: %v", secondError)
}
// Der Nachweis: Im zweiten Lauf wurde deutlich weniger gelesen.
if secondResult.BytesProcessed >= firstResult.BytesProcessed {
testInstance.Errorf("der zweite Lauf las %d Byte, der erste %d — es wurde nichts eingespart",
secondResult.BytesProcessed, firstResult.BytesProcessed)
}
var secondType string
var hasParent bool
if scanError := connectionPool.QueryRow(context.Background(),
`SELECT backup_type, parent_backup_id IS NOT NULL FROM backups WHERE job_run_id = $1`,
secondRunID).Scan(&secondType, &hasParent); scanError != nil {
testInstance.Fatalf("das zweite Backup wurde nicht vermerkt: %v", scanError)
}
if secondType != "incremental" {
testInstance.Errorf("das zweite Backup wurde als %q vermerkt", secondType)
}
if !hasParent {
testInstance.Error("die Zusatzsicherung wurde ohne Elternbackup vermerkt; die Kette hat keinen Anfang")
}
}
// TestExecutorRefusesWithoutEncryption ist ein Sicherheitstest.
//
// Ein Executor ohne Schlüssel, der trotzdem sichert, legte unverschlüsselte
// Backups an, ohne dass es jemandem auffiele. Die Kombination wird beim
// Einrichten abgelehnt, nicht erst beim ersten Lauf um zwei Uhr nachts.
func TestExecutorRefusesWithoutEncryption(testInstance *testing.T) {
_, executorError := New(&jobs.PostgresStore{}, Options{}, discardLogger())
if !errors.Is(executorError, ErrEncryptionRequired) {
testInstance.Fatalf("ein Executor ohne Schlüsselmaterial wurde angenommen: %v", executorError)
}
// Ausdrücklich zugelassen ist es erlaubt — für Testumgebungen.
if _, allowedError := New(&jobs.PostgresStore{}, Options{AllowUnencrypted: true}, discardLogger()); allowedError != nil {
testInstance.Errorf("die ausdrückliche Erlaubnis wurde abgelehnt: %v", allowedError)
}
}
// TestExecutorReportsUnknownRepository prüft die Fehlerklassifizierung.
func TestExecutorReportsUnknownRepository(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
testExecutor, _ := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
}, discardLogger())
unknownJob := &jobs.Job{
ID: uuid.New(),
Name: "Ohne Repository",
RepositoryID: uuid.New(),
Sources: []jobs.JobSource{
{SourceType: jobs.SourceTypeFilesystem, SourceID: testInstance.TempDir()},
},
}
_, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: unknownJob, RunID: uuid.New(), AttemptNumber: 1,
})
if executeError == nil {
testInstance.Fatal("ein unbekanntes Repository wurde angenommen")
}
var typedError *jobs.ExecutionError
if !errors.As(executeError, &typedError) {
testInstance.Fatalf("der Fehler war nicht eingeordnet: %v", executeError)
}
// Ein Konfigurationsfehler wird nicht wiederholt — richtig so: Er behebt
// sich nicht durch Warten.
if typedError.FailureClass.IsRetryable() {
testInstance.Errorf("ein unbekanntes Repository wurde als %q eingeordnet und damit wiederholt",
typedError.FailureClass)
}
}
// TestExecutorRefusesReadOnlyRepository prüft den gesperrten Zustand.
func TestExecutorRefusesReadOnlyRepository(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, _ := registerRepository(testInstance, connectionPool)
if _, execError := connectionPool.Exec(context.Background(),
`UPDATE repositories SET status = 'read_only' WHERE id = $1`, repositoryID); execError != nil {
testInstance.Fatalf("der Zustand ließ sich nicht setzen: %v", execError)
}
testExecutor, _ := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
}, discardLogger())
_, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: buildExecutionJob(repositoryID, buildSourceTree(testInstance)),
RunID: uuid.New(),
})
if executeError == nil {
testInstance.Fatal("ein Repository im Nur-Lese-Zustand nahm eine Sicherung an")
}
var typedError *jobs.ExecutionError
if !errors.As(executeError, &typedError) || typedError.Code != "REPOSITORY_NOT_WRITABLE" {
testInstance.Errorf("der Fehler wurde als %v gemeldet", executeError)
}
}
// TestExecutorReportsMissingSource prüft die fehlende Quelle.
func TestExecutorReportsMissingSource(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, _ := registerRepository(testInstance, connectionPool)
missingJob := buildExecutionJob(repositoryID, filepath.Join(testInstance.TempDir(), "gibt-es-nicht"))
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, missingJob)
testExecutor, _ := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
}, discardLogger())
_, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: missingJob, RunID: runIdentifier,
})
// Alle Quellen gescheitert bedeutet: der Lauf ist gescheitert.
if executeError == nil {
testInstance.Fatal("eine nicht vorhandene Quelle wurde angenommen")
}
var typedError *jobs.ExecutionError
if !errors.As(executeError, &typedError) || typedError.Code != "ALL_SOURCES_FAILED" {
testInstance.Errorf("der Fehler wurde als %v gemeldet", executeError)
}
}
// TestExecutorMarksPartialFailureWhenOneSourceFails prüft den gemischten Fall.
//
// Eine gescheiterte Quelle beendet den Lauf nicht — die übrigen sollen gesichert
// werden. Verschwiegen wird sie trotzdem nicht: Der Lauf wird zum Teilfehler.
func TestExecutorMarksPartialFailureWhenOneSourceFails(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, _ := registerRepository(testInstance, connectionPool)
mixedJob := buildExecutionJob(repositoryID, buildSourceTree(testInstance))
mixedJob.Sources = append(mixedJob.Sources, jobs.JobSource{
SourceType: jobs.SourceTypeFilesystem,
SourceID: filepath.Join(testInstance.TempDir(), "gibt-es-nicht"),
SourceName: "Fehlende Quelle",
})
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, mixedJob)
testExecutor, _ := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
}, discardLogger())
executionResult, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: mixedJob, RunID: runIdentifier,
})
if executeError != nil {
testInstance.Fatalf("eine einzelne fehlende Quelle brach den ganzen Lauf ab: %v", executeError)
}
// Die gute Quelle wurde gesichert …
if executionResult.BytesProcessed == 0 {
testInstance.Error("die vorhandene Quelle wurde nicht gesichert")
}
// … und die fehlende erscheint als übergangen, damit die Schleife den Lauf
// als Teilfehler wertet.
if executionResult.FilesSkipped == 0 {
testInstance.Fatal("die fehlende Quelle wurde nicht als übergangen ausgewiesen; der Lauf gälte als Erfolg")
}
if len(executionResult.SkipReasons) == 0 {
testInstance.Error("die Übergehung wurde nicht begründet")
}
}
// TestExecutorRejectsUnsupportedSourceType prüft nicht umgesetzte Quellarten.
//
// Eine stillschweigend übergangene Quelle ergäbe ein Backup, das vollständig
// aussieht und es nicht ist.
func TestExecutorRejectsUnsupportedSourceType(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, _ := registerRepository(testInstance, connectionPool)
// Seit Phase 7 sind Proxmox-Gäste umgesetzt; geprüft wird deshalb mit einer
// Art, die es weiterhin nicht ist. Der Sinn des Tests bleibt derselbe: Eine
// nicht unterstützte Quelle wird benannt, nicht stillschweigend übergangen
// — sonst entstünde ein Backup, das vollständig aussieht und es nicht ist.
virtualJob := buildExecutionJob(repositoryID, buildSourceTree(testInstance))
virtualJob.Sources = []jobs.JobSource{
{SourceType: jobs.SourceTypeWindowsSystem, SourceID: "WINSRV01", SourceName: "Dateiserver"},
}
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, virtualJob)
testExecutor, _ := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
}, discardLogger())
_, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: virtualJob, RunID: runIdentifier,
})
if executeError == nil {
testInstance.Fatal("eine nicht unterstützte Quellart wurde stillschweigend übergangen")
}
if !strings.Contains(executeError.Error(), "windows_system") {
testInstance.Errorf("die Fehlermeldung nennt die Quellart nicht: %v", executeError)
}
}
// TestBackupIdentifierIsFileSafe prüft die Kennungsbildung.
//
// Eine Backup-Kennung wird zum Dateinamen des Manifests. Ein Pfad mit
// Schrägstrichen erzeugte dort Unterverzeichnisse, ein Punkt am Anfang eine
// versteckte Datei.
func TestBackupIdentifierIsFileSafe(testInstance *testing.T) {
runIdentifier := uuid.New()
problematicSources := []string{
"/daten/kunden",
"../../etc/passwd",
"C:\\Users\\Test",
".versteckt",
"qemu/100",
"",
}
for _, sourceIdentifier := range problematicSources {
builtIdentifier := buildBackupIdentifier(runIdentifier,
jobs.JobSource{SourceType: jobs.SourceTypeFilesystem, SourceID: sourceIdentifier})
if strings.ContainsAny(builtIdentifier, `/\`) {
testInstance.Errorf("die Kennung für %q enthält Pfadtrenner: %q", sourceIdentifier, builtIdentifier)
}
if strings.HasPrefix(builtIdentifier, ".") {
testInstance.Errorf("die Kennung für %q beginnt mit einem Punkt: %q", sourceIdentifier, builtIdentifier)
}
if strings.Contains(builtIdentifier, "..") {
testInstance.Errorf("die Kennung für %q enthält einen Pfadausbruch: %q", sourceIdentifier, builtIdentifier)
}
// Die Obergrenze des Repositorys darf nicht überschritten werden — sonst
// scheitert jede Sicherung dieser Quelle, und zwar erst beim Lauf.
if len(builtIdentifier) > maximumBackupIdentifierLength {
testInstance.Errorf("die Kennung für %q ist %d Zeichen lang, zulässig sind %d: %q",
sourceIdentifier, len(builtIdentifier), maximumBackupIdentifierLength, builtIdentifier)
}
// Die Laufkennung muss enthalten sein: Sie erlaubt es, von einem Backup
// im Repository aus den erzeugenden Lauf zurückzuverfolgen — auch dann,
// wenn die Datenbank verloren ging.
if !strings.Contains(builtIdentifier, runIdentifier.String()) {
testInstance.Errorf("die Kennung %q enthält die Laufkennung nicht", builtIdentifier)
}
}
}
// TestExecutorRespectsExcludePatterns prüft die Ausschlussregeln.
func TestExecutorRespectsExcludePatterns(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, repositoryPath := registerRepository(testInstance, connectionPool)
sourcePath := buildSourceTree(testInstance)
// Eine Datei, die ausgeschlossen wird.
if writeError := os.WriteFile(filepath.Join(sourcePath, "gross.tmp"),
bytes.Repeat([]byte("x"), 1024), 0o644); writeError != nil {
testInstance.Fatalf("die Datei ließ sich nicht anlegen: %v", writeError)
}
excludingJob := buildExecutionJob(repositoryID, sourcePath)
excludingJob.Sources[0].ExcludePatterns = []string{"*.tmp"}
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, excludingJob)
testExecutor, _ := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
}, discardLogger())
if _, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: excludingJob, RunID: runIdentifier,
}); executeError != nil {
testInstance.Fatalf("die Ausführung schlug fehl: %v", executeError)
}
openedRepository, _ := repository.Open(context.Background(), repositoryPath,
repository.OpenOptions{ReadOnly: true}, discardLogger())
defer func() { _ = openedRepository.Close() }()
engine := backupengine.NewEngine(openedRepository, buildTestSecretStore(testInstance), discardLogger())
catalogEntries, _ := openedRepository.ListBackups(context.Background())
if len(catalogEntries) == 0 {
testInstance.Fatal("es wurde kein Backup angelegt")
}
manifestEntries, listError := engine.ListEntries(context.Background(), catalogEntries[0].BackupID)
if listError != nil {
testInstance.Fatalf("die Einträge ließen sich nicht lesen: %v", listError)
}
for _, manifestEntry := range manifestEntries {
if strings.HasSuffix(manifestEntry.Path, ".tmp") {
testInstance.Errorf("die ausgeschlossene Datei %q wurde gesichert", manifestEntry.Path)
}
}
}
// TestExecutorAppliesJobBandwidthLimit prüft, dass die Grenze des Auftrags
// tatsächlich im Datenpfad ankommt.
//
// Dieser Test schließt eine Lücke, die im Betrieb aufgefallen ist: Der
// Begrenzer wurde gebaut, protokolliert — und beim Bau der Quellanfrage
// vergessen. Ein Feld, das gesetzt aussieht und nie ankommt, ist im Protokoll
// nicht zu erkennen: Die Zeile „der lauf ist in der bandbreite begrenzt"
// erschien, während die Sicherung mit voller Geschwindigkeit lief.
func TestExecutorAppliesJobBandwidthLimit(testInstance *testing.T) {
if testing.Short() {
testInstance.Skip("die Durchsatzmessung braucht echte Zeit")
}
connectionPool := connectTestDatabase(testInstance)
testStore := jobs.NewPostgresStore(connectionPool)
repositoryID, _ := registerRepository(testInstance, connectionPool)
// Genug Daten, damit die Grenze messbar wirkt: 4 MiB bei 1 MiB/s.
sourcePath := testInstance.TempDir()
incompressibleData := make([]byte, 4<<20)
var generatorState uint64 = 0x2545F4914F6CDD1D
for byteIndex := range incompressibleData {
generatorState ^= generatorState << 13
generatorState ^= generatorState >> 7
generatorState ^= generatorState << 17
incompressibleData[byteIndex] = byte(generatorState)
}
if writeError := os.WriteFile(filepath.Join(sourcePath, "gross.bin"),
incompressibleData, 0o644); writeError != nil {
testInstance.Fatalf("die Datei ließ sich nicht anlegen: %v", writeError)
}
limitedJob := buildExecutionJob(repositoryID, sourcePath)
limitedJob.BandwidthLimitBytesPerSecond = 1 << 20
runIdentifier := createRunRow(testInstance, connectionPool, repositoryID, limitedJob)
testExecutor, _ := New(testStore, Options{
SecretStore: buildTestSecretStore(testInstance),
CompressionLevel: backupengine.CompressionOff,
}, discardLogger())
startTime := time.Now()
executionResult, executeError := testExecutor.Execute(context.Background(), jobs.ExecutionRequest{
Job: limitedJob, RunID: runIdentifier, AttemptNumber: 1,
})
if executeError != nil {
testInstance.Fatalf("die Ausführung schlug fehl: %v", executeError)
}
elapsedDuration := time.Since(startTime)
if executionResult.BytesProcessed < int64(len(incompressibleData)) {
testInstance.Fatalf("es wurden nur %d von %d Byte gelesen",
executionResult.BytesProcessed, len(incompressibleData))
}
// Der Eimer liefert eine Sekunde Vorrat; für die übrigen 3 MiB muss
// gewartet werden. Ohne wirksame Grenze liefe der Lauf in Millisekunden.
const minimumExpectedDuration = 2 * time.Second
if elapsedDuration < minimumExpectedDuration {
testInstance.Fatalf("4 MiB bei 1 MiB/s liefen in %s durch; die Grenze des Auftrags kommt nicht im Datenpfad an",
elapsedDuration)
}
testInstance.Logf("4 MiB bei einer Auftragsgrenze von 1 MiB/s: %s", elapsedDuration.Round(time.Millisecond))
}