syncova-backup/packages/backupengine/engine_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

908 lines
32 KiB
Go

package backupengine
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"math/rand"
"testing"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/repository"
)
// newTestLogger liefert einen Logger, der ins Nichts schreibt.
func newTestLogger() *slog.Logger {
return slog.New(slog.NewJSONHandler(io.Discard, nil))
}
// newTestSecretStore baut einen Secret Store für Tests.
func newTestSecretStore(testInstance *testing.T) crypto.SecretStore {
testInstance.Helper()
testKey := bytes.Repeat([]byte{0x42}, 32)
secretStore, storeError := crypto.NewLocalSecretStore(map[string][]byte{"v1": testKey}, "v1")
if storeError != nil {
testInstance.Fatalf("der Secret Store konnte nicht erstellt werden: %v", storeError)
}
return secretStore
}
// newTestEngine baut eine Engine über einem frischen Repository.
func newTestEngine(testInstance *testing.T) (*Engine, *repository.LocalRepository) {
testInstance.Helper()
testRepository, createError := repository.Create(context.Background(), testInstance.TempDir(),
repository.CreateOptions{Name: "Test", CreatedByVersion: "test"}, newTestLogger())
if createError != nil {
testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError)
}
testInstance.Cleanup(func() { _ = testRepository.Close() })
return NewEngine(testRepository, newTestSecretStore(testInstance), newTestLogger()), testRepository
}
// buildDeterministicData erzeugt reproduzierbare Testdaten.
//
// Ein fester Startwert macht Fehlschläge nachvollziehbar; echte Zufallsdaten
// ergäben bei jedem Lauf ein anderes Bild.
func buildDeterministicData(byteCount int, randomSeed int64) []byte {
randomSource := rand.New(rand.NewSource(randomSeed))
generatedData := make([]byte, byteCount)
_, _ = randomSource.Read(generatedData)
return generatedData
}
// runBackup führt ein Backup über den übergebenen Daten aus.
func runBackup(testInstance *testing.T, engine *Engine, backupID string, sourceData []byte, backupOptions BackupOptions) BackupResult {
testInstance.Helper()
backupOptions.BackupID = backupID
backupOptions.Source = repository.SourceInformation{SourceType: "filesystem", SourceID: "quelle-1"}
backupOptions.CreatedByVersion = "test"
backupResult, backupError := engine.Backup(context.Background(), backupOptions, []BackupSource{{
Path: "/daten/test.bin",
EntryType: "file",
Reader: bytes.NewReader(sourceData),
}})
if backupError != nil {
testInstance.Fatalf("das Backup schlug fehl: %v", backupError)
}
return backupResult
}
// ---------------------------------------------------------------------------
// Rundreise
// ---------------------------------------------------------------------------
func TestBackupAndRestoreRoundTrip(testInstance *testing.T) {
engine, _ := newTestEngine(testInstance)
originalData := buildDeterministicData(5*1024*1024, 42)
backupResult := runBackup(testInstance, engine, "backup-1", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
})
if backupResult.Progress.BytesProcessed != int64(len(originalData)) {
testInstance.Errorf("es sollen %d Byte verarbeitet werden, waren %d",
len(originalData), backupResult.Progress.BytesProcessed)
}
var restoredBuffer bytes.Buffer
restoreResult, restoreError := engine.Restore(context.Background(), RestoreOptions{
BackupID: "backup-1",
Path: "/daten/test.bin",
}, &restoredBuffer)
if restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
// Das entscheidende Kriterium: die Daten müssen bitgenau zurückkommen.
if !bytes.Equal(restoredBuffer.Bytes(), originalData) {
testInstance.Fatal("die wiederhergestellten Daten weichen von den gesicherten ab")
}
if !restoreResult.Verified {
testInstance.Error("die Wiederherstellung soll gegen die Inhaltsprüfsumme geprüft sein")
}
}
func TestRoundTripAcrossAllCompressionLevels(testInstance *testing.T) {
compressionLevels := []CompressionLevel{
CompressionOff, CompressionFast, CompressionBalanced, CompressionMaximum,
}
// Gut komprimierbare Daten zeigen den Unterschied zwischen den Stufen.
originalData := bytes.Repeat([]byte("Diese Zeile wiederholt sich sehr oft. "), 50000)
for _, compressionLevel := range compressionLevels {
engine, _ := newTestEngine(testInstance)
backupResult := runBackup(testInstance, engine, "backup-1", originalData, BackupOptions{
CompressionLevel: compressionLevel,
EncryptionEnabled: true,
})
var restoredBuffer bytes.Buffer
if _, restoreError := engine.Restore(context.Background(), RestoreOptions{
BackupID: "backup-1", Path: "/daten/test.bin",
}, &restoredBuffer); restoreError != nil {
testInstance.Fatalf("%s: die Wiederherstellung schlug fehl: %v", compressionLevel, restoreError)
}
if !bytes.Equal(restoredBuffer.Bytes(), originalData) {
testInstance.Fatalf("%s: die Daten weichen nach der Rundreise ab", compressionLevel)
}
// Bei eingeschalteter Kompression müssen die Daten kleiner werden.
if compressionLevel.IsEnabled() && backupResult.Progress.BytesWritten >= backupResult.Progress.BytesProcessed {
testInstance.Errorf("%s: es wurde nichts eingespart (%d von %d Byte)",
compressionLevel, backupResult.Progress.BytesWritten, backupResult.Progress.BytesProcessed)
}
}
}
func TestRoundTripWithoutEncryption(testInstance *testing.T) {
engine, _ := newTestEngine(testInstance)
originalData := buildDeterministicData(1024*1024, 7)
runBackup(testInstance, engine, "backup-1", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: false,
})
var restoredBuffer bytes.Buffer
if _, restoreError := engine.Restore(context.Background(), RestoreOptions{
BackupID: "backup-1", Path: "/daten/test.bin",
}, &restoredBuffer); restoreError != nil {
testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError)
}
if !bytes.Equal(restoredBuffer.Bytes(), originalData) {
testInstance.Fatal("die Daten weichen nach der Rundreise ab")
}
}
func TestRoundTripWithSmallAndEmptyData(testInstance *testing.T) {
dataSizes := map[string]int{
"ein Byte": 1,
"unter Mindestgröße": 1000,
"genau ein Block": MinimumChunkSize,
}
for sizeDescription, dataSize := range dataSizes {
engine, _ := newTestEngine(testInstance)
originalData := buildDeterministicData(dataSize, 1)
runBackup(testInstance, engine, "backup-1", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
})
var restoredBuffer bytes.Buffer
if _, restoreError := engine.Restore(context.Background(), RestoreOptions{
BackupID: "backup-1", Path: "/daten/test.bin",
}, &restoredBuffer); restoreError != nil {
testInstance.Fatalf("%s: die Wiederherstellung schlug fehl: %v", sizeDescription, restoreError)
}
if !bytes.Equal(restoredBuffer.Bytes(), originalData) {
testInstance.Errorf("%s: die Daten weichen ab", sizeDescription)
}
}
}
// ---------------------------------------------------------------------------
// Verschlüsselung
// ---------------------------------------------------------------------------
func TestStoredChunksAreNotReadableWithoutKey(testInstance *testing.T) {
engine, testRepository := newTestEngine(testInstance)
// Ein wiedererkennbarer Klartext macht den Nachweis eindeutig.
recognizableContent := bytes.Repeat([]byte("GEHEIMER-INHALT-"), 20000)
backupResult := runBackup(testInstance, engine, "backup-1", recognizableContent, BackupOptions{
// Ohne Kompression bliebe der Klartext bei fehlender Verschlüsselung
// unverändert erhalten - das macht die Prüfung aussagekräftig.
CompressionLevel: CompressionOff,
EncryptionEnabled: true,
})
if !backupResult.Encrypted {
testInstance.Fatal("das Backup soll als verschlüsselt gelten")
}
manifest, _ := testRepository.ReadManifest(context.Background(), "backup-1")
for _, chunkReference := range manifest.Entries[0].Chunks {
storedChunk, readError := testRepository.ReadStoredChunk(context.Background(),
chunkReference.Identifier, chunkReference.StoredDigest)
if readError != nil {
testInstance.Fatalf("der abgelegte Block konnte nicht gelesen werden: %v", readError)
}
// Im abgelegten Block darf der Klartext nicht auffindbar sein.
if bytes.Contains(storedChunk, []byte("GEHEIMER-INHALT")) {
testInstance.Fatal("der Klartext steht unverschlüsselt im Repository")
}
}
}
func TestRestoreFailsWithWrongMasterKey(testInstance *testing.T) {
testRepository, _ := repository.Create(context.Background(), testInstance.TempDir(),
repository.CreateOptions{Name: "Test"}, newTestLogger())
defer func() { _ = testRepository.Close() }()
correctKey := bytes.Repeat([]byte{0x42}, 32)
correctStore, _ := crypto.NewLocalSecretStore(map[string][]byte{"v1": correctKey}, "v1")
writingEngine := NewEngine(testRepository, correctStore, newTestLogger())
originalData := buildDeterministicData(512*1024, 3)
runBackup(testInstance, writingEngine, "backup-1", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
})
// Ein anderer übergeordneter Schlüssel darf das Backup nicht öffnen.
wrongKey := bytes.Repeat([]byte{0x99}, 32)
wrongStore, _ := crypto.NewLocalSecretStore(map[string][]byte{"v1": wrongKey}, "v1")
readingEngine := NewEngine(testRepository, wrongStore, newTestLogger())
var restoredBuffer bytes.Buffer
_, restoreError := readingEngine.Restore(context.Background(), RestoreOptions{
BackupID: "backup-1", Path: "/daten/test.bin",
}, &restoredBuffer)
if restoreError == nil {
testInstance.Fatal("mit falschem Schlüssel darf keine Wiederherstellung gelingen")
}
}
func TestRestoreFailsWithoutSecretStore(testInstance *testing.T) {
engine, testRepository := newTestEngine(testInstance)
runBackup(testInstance, engine, "backup-1", buildDeterministicData(256*1024, 5), BackupOptions{
EncryptionEnabled: true,
})
// Eine Engine ohne Schlüsselspeicher kann ein verschlüsseltes Backup nicht lesen.
engineWithoutStore := NewEngine(testRepository, nil, newTestLogger())
var restoredBuffer bytes.Buffer
_, restoreError := engineWithoutStore.Restore(context.Background(), RestoreOptions{
BackupID: "backup-1", Path: "/daten/test.bin",
}, &restoredBuffer)
if !errors.Is(restoreError, ErrEncryptionUnavailable) {
testInstance.Fatalf("ohne Schlüsselspeicher muss die Wiederherstellung scheitern, war: %v", restoreError)
}
}
func TestBackupRefusesEncryptionWithoutSecretStore(testInstance *testing.T) {
testRepository, _ := repository.Create(context.Background(), testInstance.TempDir(),
repository.CreateOptions{Name: "Test"}, newTestLogger())
defer func() { _ = testRepository.Close() }()
engineWithoutStore := NewEngine(testRepository, nil, newTestLogger())
// Eine angeforderte, aber nicht mögliche Verschlüsselung darf nicht
// stillschweigend übergangen werden.
_, backupError := engineWithoutStore.Backup(context.Background(), BackupOptions{
BackupID: "backup-1",
EncryptionEnabled: true,
}, []BackupSource{{Path: "/x", Reader: bytes.NewReader([]byte("daten"))}})
if !errors.Is(backupError, ErrEncryptionUnavailable) {
testInstance.Fatalf("erwartet wurde ErrEncryptionUnavailable, war: %v", backupError)
}
}
// ---------------------------------------------------------------------------
// Deduplizierung
// ---------------------------------------------------------------------------
func TestSecondBackupOfSameDataIsFullyDeduplicated(testInstance *testing.T) {
engine, _ := newTestEngine(testInstance)
originalData := buildDeterministicData(3*1024*1024, 11)
runBackup(testInstance, engine, "backup-1", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
})
secondResult := runBackup(testInstance, engine, "backup-2", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
})
// Trotz Verschlüsselung muss die Deduplizierung greifen: die Kennung
// entsteht über dem Klartext, nicht über dem Geheimtext.
if secondResult.Progress.ChunksWritten != 0 {
testInstance.Errorf("der zweite Lauf soll nichts neu schreiben, waren %d Blöcke",
secondResult.Progress.ChunksWritten)
}
if secondResult.Progress.ChunksDeduplicated == 0 {
testInstance.Error("der zweite Lauf soll Blöcke wiederverwenden")
}
// Beide Backups müssen unabhängig voneinander wiederherstellbar sein.
for _, backupID := range []string{"backup-1", "backup-2"} {
var restoredBuffer bytes.Buffer
if _, restoreError := engine.Restore(context.Background(), RestoreOptions{
BackupID: backupID, Path: "/daten/test.bin",
}, &restoredBuffer); restoreError != nil {
testInstance.Fatalf("%s: die Wiederherstellung schlug fehl: %v", backupID, restoreError)
}
if !bytes.Equal(restoredBuffer.Bytes(), originalData) {
testInstance.Errorf("%s: die Daten weichen ab", backupID)
}
}
}
func TestInsertionShiftsOnlyNearbyChunks(testInstance *testing.T) {
engine, _ := newTestEngine(testInstance)
// Eine Einfügung mitten in den Daten. Bei fester Blockaufteilung würden
// dadurch alle folgenden Blöcke verschoben und wären neu; inhaltsabhängige
// Grenzen wandern dagegen mit.
originalData := buildDeterministicData(8*1024*1024, 13)
firstResult := runBackup(testInstance, engine, "backup-1", originalData, BackupOptions{
CompressionLevel: CompressionOff,
EncryptionEnabled: false,
})
insertionPoint := len(originalData) / 2
modifiedData := make([]byte, 0, len(originalData)+64)
modifiedData = append(modifiedData, originalData[:insertionPoint]...)
modifiedData = append(modifiedData, bytes.Repeat([]byte("EINGEFUEGT"), 6)...)
modifiedData = append(modifiedData, originalData[insertionPoint:]...)
secondResult := runBackup(testInstance, engine, "backup-2", modifiedData, BackupOptions{
CompressionLevel: CompressionOff,
EncryptionEnabled: false,
})
totalChunks := secondResult.Progress.ChunksWritten + secondResult.Progress.ChunksDeduplicated
// Der weitaus größte Teil der Blöcke muss wiedererkannt werden. Bei fester
// Aufteilung wären es nahezu null.
if secondResult.Progress.ChunksDeduplicated < totalChunks/2 {
testInstance.Errorf("nach einer Einfügung sollen die meisten Blöcke wiederverwendet werden: %d von %d",
secondResult.Progress.ChunksDeduplicated, totalChunks)
}
testInstance.Logf("erster Lauf: %d Blöcke, zweiter Lauf: %d neu / %d wiederverwendet",
firstResult.Progress.ChunksWritten, secondResult.Progress.ChunksWritten, secondResult.Progress.ChunksDeduplicated)
}
// ---------------------------------------------------------------------------
// Integrität
// ---------------------------------------------------------------------------
func TestRestoreDetectsCorruptedStoredChunk(testInstance *testing.T) {
engine, testRepository := newTestEngine(testInstance)
originalData := buildDeterministicData(1024*1024, 17)
runBackup(testInstance, engine, "backup-1", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
})
manifest, _ := testRepository.ReadManifest(context.Background(), "backup-1")
firstChunk := manifest.Entries[0].Chunks[0]
// Ein Bitfehler auf dem Datenträger.
corruptChunkOnDisk(testInstance, testRepository, firstChunk.Identifier)
var restoredBuffer bytes.Buffer
_, restoreError := engine.Restore(context.Background(), RestoreOptions{
BackupID: "backup-1", Path: "/daten/test.bin",
}, &restoredBuffer)
// Ein beschädigter Block darf niemals unbemerkt zurückgeschrieben werden.
if restoreError == nil {
testInstance.Fatal("die Beschädigung wurde nicht erkannt")
}
}
func TestManifestCarriesStoredDigestForIntegrityScan(testInstance *testing.T) {
engine, testRepository := newTestEngine(testInstance)
runBackup(testInstance, engine, "backup-1", buildDeterministicData(1024*1024, 19), BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
})
manifest, _ := testRepository.ReadManifest(context.Background(), "backup-1")
// Ohne die Prüfsumme der abgelegten Form könnte ein Integritätslauf einen
// verschlüsselten Block nicht ohne Schlüssel prüfen.
for _, chunkReference := range manifest.Entries[0].Chunks {
if chunkReference.StoredDigest == "" {
testInstance.Fatal("dem Blockverweis fehlt die Prüfsumme der abgelegten Form")
}
if verifyError := testRepository.VerifyStoredChunk(context.Background(),
chunkReference.Identifier, chunkReference.StoredDigest); verifyError != nil {
testInstance.Errorf("die Prüfung des abgelegten Blocks schlug fehl: %v", verifyError)
}
}
}
// corruptChunkOnDisk verfälscht einen abgelegten Block.
func corruptChunkOnDisk(testInstance *testing.T, testRepository *repository.LocalRepository, chunkIdentifier string) {
testInstance.Helper()
storedChunk, readError := testRepository.ReadStoredChunk(context.Background(), chunkIdentifier, "")
if readError != nil {
testInstance.Fatalf("der Block konnte nicht gelesen werden: %v", readError)
}
corruptedChunk := bytes.Clone(storedChunk)
corruptedChunk[len(corruptedChunk)/2] ^= 0xff
if _, _, writeError := testRepository.WriteTransformedChunkForTest(chunkIdentifier, corruptedChunk); writeError != nil {
testInstance.Fatalf("der Block konnte nicht verfälscht werden: %v", writeError)
}
}
// ---------------------------------------------------------------------------
// Abbruch und Fehlerfälle
// ---------------------------------------------------------------------------
func TestBackupHonoursCancellation(testInstance *testing.T) {
engine, testRepository := newTestEngine(testInstance)
backupContext, cancelBackup := context.WithCancel(context.Background())
// Der Abbruch erfolgt, während noch gelesen wird.
slowReader := &cancellingReader{
remainingBytes: 50 * 1024 * 1024,
cancelAfter: 2 * 1024 * 1024,
cancelFunction: cancelBackup,
}
_, backupError := engine.Backup(backupContext, BackupOptions{
BackupID: "abgebrochen",
Source: repository.SourceInformation{SourceType: "filesystem"},
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
}, []BackupSource{{Path: "/gross.bin", EntryType: "file", Reader: slowReader}})
if backupError == nil {
testInstance.Fatal("ein abgebrochenes Backup darf nicht als erfolgreich gelten")
}
// Ein abgebrochener Lauf darf kein sichtbares Backup hinterlassen.
if _, readError := testRepository.ReadManifest(context.Background(), "abgebrochen"); !errors.Is(readError, repository.ErrBackupNotFound) {
testInstance.Fatalf("ein abgebrochener Lauf darf kein Backup hinterlassen, war: %v", readError)
}
}
// cancellingReader bricht den Vorgang nach einer bestimmten Menge ab.
type cancellingReader struct {
// remainingBytes ist die noch zu liefernde Menge.
remainingBytes int
// deliveredBytes ist die bisher gelieferte Menge.
deliveredBytes int
// cancelAfter ist die Menge, nach der abgebrochen wird.
cancelAfter int
// cancelFunction bricht den Vorgang ab.
cancelFunction context.CancelFunc
}
// Read liefert Daten und bricht nach der festgelegten Menge ab.
func (reader *cancellingReader) Read(targetBuffer []byte) (int, error) {
if reader.remainingBytes <= 0 {
return 0, io.EOF
}
deliveredNow := min(len(targetBuffer), reader.remainingBytes)
for byteIndex := range targetBuffer[:deliveredNow] {
targetBuffer[byteIndex] = byte(byteIndex)
}
reader.remainingBytes -= deliveredNow
reader.deliveredBytes += deliveredNow
if reader.deliveredBytes >= reader.cancelAfter {
reader.cancelFunction()
}
return deliveredNow, nil
}
func TestBackupRejectsEmptySourceList(testInstance *testing.T) {
engine, _ := newTestEngine(testInstance)
// Ein Backup ohne Quellen wäre ein leeres Gebilde, das sich als
// erfolgreiche Sicherung ausgäbe.
if _, backupError := engine.Backup(context.Background(), BackupOptions{BackupID: "leer"}, nil); backupError == nil {
testInstance.Fatal("ein Backup ohne Quellen muss abgelehnt werden")
}
}
func TestRestoreReportsUnknownPath(testInstance *testing.T) {
engine, _ := newTestEngine(testInstance)
runBackup(testInstance, engine, "backup-1", buildDeterministicData(1024, 23), BackupOptions{
EncryptionEnabled: true,
})
var restoredBuffer bytes.Buffer
_, restoreError := engine.Restore(context.Background(), RestoreOptions{
BackupID: "backup-1", Path: "/gibt/es/nicht",
}, &restoredBuffer)
if !errors.Is(restoreError, ErrManifestEntryNotFound) {
testInstance.Fatalf("ein unbekanntes Objekt muss gemeldet werden, war: %v", restoreError)
}
}
// ---------------------------------------------------------------------------
// Mehrere Quellen und Fortschritt
// ---------------------------------------------------------------------------
func TestBackupWithSeveralSources(testInstance *testing.T) {
engine, _ := newTestEngine(testInstance)
sourceContents := map[string][]byte{
"/daten/a.bin": buildDeterministicData(512*1024, 31),
"/daten/b.bin": buildDeterministicData(512*1024, 37),
"/daten/c.bin": buildDeterministicData(512*1024, 41),
}
backupSources := make([]BackupSource, 0, len(sourceContents))
for sourcePath, sourceData := range sourceContents {
backupSources = append(backupSources, BackupSource{
Path: sourcePath, EntryType: "file", Reader: bytes.NewReader(sourceData),
})
}
backupResult, backupError := engine.Backup(context.Background(), BackupOptions{
BackupID: "mehrere",
Source: repository.SourceInformation{SourceType: "filesystem"},
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
CreatedByVersion: "test",
}, backupSources)
if backupError != nil {
testInstance.Fatalf("das Backup schlug fehl: %v", backupError)
}
if backupResult.EntryCount != 3 {
testInstance.Errorf("es sollen 3 Objekte gesichert sein, waren %d", backupResult.EntryCount)
}
// Jedes Objekt muss einzeln wiederherstellbar sein.
for sourcePath, expectedData := range sourceContents {
var restoredBuffer bytes.Buffer
if _, restoreError := engine.Restore(context.Background(), RestoreOptions{
BackupID: "mehrere", Path: sourcePath,
}, &restoredBuffer); restoreError != nil {
testInstance.Fatalf("%s: die Wiederherstellung schlug fehl: %v", sourcePath, restoreError)
}
if !bytes.Equal(restoredBuffer.Bytes(), expectedData) {
testInstance.Errorf("%s: die Daten weichen ab", sourcePath)
}
}
}
func TestProgressIsReported(testInstance *testing.T) {
engine, _ := newTestEngine(testInstance)
var lastProgress Progress
var reportCount int
runBackup(testInstance, engine, "backup-1", buildDeterministicData(4*1024*1024, 43), BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
ProgressCallback: func(progress Progress) {
lastProgress = progress
reportCount++
},
})
if reportCount == 0 {
testInstance.Fatal("es wurde kein Fortschritt gemeldet")
}
if lastProgress.BytesProcessed != 4*1024*1024 {
testInstance.Errorf("der Endstand soll 4 MiB melden, waren %d", lastProgress.BytesProcessed)
}
if lastProgress.ThroughputBytesPerSecond <= 0 {
testInstance.Error("es wurde kein Durchsatz gemeldet")
}
}
func TestProgressRatioIsUndefinedWhenNothingWasStored(testInstance *testing.T) {
// Wurde jeder Block wiederverwendet, existiert kein endliches Verhältnis.
fullyDeduplicatedProgress := Progress{BytesProcessed: 1000, BytesWritten: 0}
if _, isDefined := fullyDeduplicatedProgress.CompressionRatio(); isDefined {
testInstance.Error("ohne abgelegte Daten darf kein Verhältnis gemeldet werden")
}
// Die Ersparnis bleibt dennoch bestimmbar.
if fullyDeduplicatedProgress.SavingsPercentage() != 100 {
testInstance.Errorf("die Ersparnis soll 100 %% sein, war %.1f", fullyDeduplicatedProgress.SavingsPercentage())
}
}
func TestWorkerCountValidation(testInstance *testing.T) {
// Ein negativer Wert ist ein Konfigurationsfehler.
if _, validationError := validateWorkerCount(-1); validationError == nil {
testInstance.Error("eine negative Arbeiterzahl muss abgelehnt werden")
}
// Null wählt einen sinnvollen Wert.
automaticCount, _ := validateWorkerCount(0)
if automaticCount < 1 {
testInstance.Errorf("die automatische Arbeiterzahl soll mindestens 1 sein, war %d", automaticCount)
}
}
func TestSingleWorkerProducesSameResult(testInstance *testing.T) {
originalData := buildDeterministicData(3*1024*1024, 47)
// Die Zahl der Arbeiter darf das Ergebnis nicht verändern: die Reihenfolge
// wird über die Positionsnummer wiederhergestellt.
var manifestHashes []string
for _, workerCount := range []int{1, 4} {
engine, testRepository := newTestEngine(testInstance)
runBackup(testInstance, engine, "backup-1", originalData, BackupOptions{
CompressionLevel: CompressionOff,
EncryptionEnabled: false,
WorkerCount: workerCount,
})
manifest, _ := testRepository.ReadManifest(context.Background(), "backup-1")
chunkSequence := ""
for _, chunkReference := range manifest.Entries[0].Chunks {
chunkSequence += chunkReference.Identifier[:8] + ","
}
manifestHashes = append(manifestHashes, chunkSequence)
}
if manifestHashes[0] != manifestHashes[1] {
testInstance.Fatal("die Blockreihenfolge hängt von der Zahl der Arbeiter ab")
}
}
func TestLargeDatasetStaysWithinMemoryBudget(testInstance *testing.T) {
if testing.Short() {
testInstance.Skip("der Test verarbeitet 64 MiB und läuft nicht im Kurzmodus")
}
engine, _ := newTestEngine(testInstance)
// Ein Datenstrom, der nie vollständig im Speicher liegt: er wird erzeugt,
// während er gelesen wird.
const datasetSize = 64 * 1024 * 1024
streamingSource := &generatedReader{remainingBytes: datasetSize}
backupResult, backupError := engine.Backup(context.Background(), BackupOptions{
BackupID: "grosser-datensatz",
Source: repository.SourceInformation{SourceType: "filesystem"},
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
WorkerCount: 4,
CreatedByVersion: "test",
}, []BackupSource{{Path: "/gross.bin", EntryType: "file", Reader: streamingSource}})
if backupError != nil {
testInstance.Fatalf("das Backup schlug fehl: %v", backupError)
}
if backupResult.Progress.BytesProcessed != datasetSize {
testInstance.Errorf("es sollen %d Byte verarbeitet werden, waren %d",
datasetSize, backupResult.Progress.BytesProcessed)
}
testInstance.Logf("64 MiB verarbeitet in %s, %.1f MiB/s, %d Blöcke",
backupResult.Duration.Round(1e6),
backupResult.Progress.ThroughputBytesPerSecond/(1024*1024),
backupResult.Progress.ChunksWritten)
}
// generatedReader erzeugt Daten beim Lesen, ohne sie vorzuhalten.
type generatedReader struct {
// remainingBytes ist die noch zu liefernde Menge.
remainingBytes int
// position ist die bisherige Position im Strom.
position int
}
// Read füllt den Puffer mit reproduzierbaren Daten.
func (reader *generatedReader) Read(targetBuffer []byte) (int, error) {
if reader.remainingBytes <= 0 {
return 0, io.EOF
}
deliveredNow := min(len(targetBuffer), reader.remainingBytes)
for byteIndex := range targetBuffer[:deliveredNow] {
// Ein einfaches, aber nicht gleichförmiges Muster: gleichförmige Daten
// erzeugten unrealistisch wenige Blockgrenzen.
reader.position++
targetBuffer[byteIndex] = byte(reader.position * 7 % 251)
}
reader.remainingBytes -= deliveredNow
return deliveredNow, nil
}
func TestChunkerProducesStableBoundaries(testInstance *testing.T) {
sourceData := buildDeterministicData(16*1024*1024, 53)
// Zweimal dieselben Daten müssen dieselben Grenzen ergeben - andernfalls
// wäre keine Deduplizierung möglich.
firstBoundaries := collectChunkSizes(testInstance, sourceData)
secondBoundaries := collectChunkSizes(testInstance, sourceData)
if fmt.Sprint(firstBoundaries) != fmt.Sprint(secondBoundaries) {
testInstance.Fatal("die Blockgrenzen sind nicht reproduzierbar")
}
// Die Blöcke müssen innerhalb der Grenzen liegen.
for _, chunkSize := range firstBoundaries {
if chunkSize > MaximumChunkSize {
testInstance.Errorf("ein Block überschreitet die Höchstgröße: %d", chunkSize)
}
}
}
// collectChunkSizes zerlegt Daten und liefert die Blockgrößen.
func collectChunkSizes(testInstance *testing.T, sourceData []byte) []int {
testInstance.Helper()
streamChunker := NewChunker(bytes.NewReader(sourceData), ChunkerOptions{})
var chunkSizes []int
var totalBytes int
for {
nextChunk, chunkError := streamChunker.Next()
if errors.Is(chunkError, io.EOF) {
break
}
if chunkError != nil {
testInstance.Fatalf("das Chunking schlug fehl: %v", chunkError)
}
chunkSizes = append(chunkSizes, len(nextChunk.Data))
totalBytes += len(nextChunk.Data)
}
// Es darf kein Byte verloren gehen.
if totalBytes != len(sourceData) {
testInstance.Fatalf("das Chunking lieferte %d von %d Byte", totalBytes, len(sourceData))
}
return chunkSizes
}
// TestManifestCarriesRealStatistics ist der Regressionstest zum Fund aus Phase 18.
//
// Die Manifest-Statistik war strukturell **null**: Die Engine schrieb ueber
// WriteTransformedChunk unmittelbar am Repository und damit an der Zaehlung der
// Schreibsession vorbei. Alle Tests blieben gruen, weil keiner das Manifest auf
// seine Kennzahlen ansah.
//
// Aufgefallen ist es erst, als das Repository zum ersten Mal als **alleinige**
// Quelle diente: Nach dem Verlust der Datenbank meldete jedes Backup die Groesse
// null. Ein Repository, das seine eigene Groesse nicht kennt, ist nicht
// selbstbeschreibend — und das ist die zentrale Zusage des Produkts.
func TestManifestCarriesRealStatistics(testInstance *testing.T) {
engine, testRepository := newTestEngine(testInstance)
// Inkompressible Daten: Sonst schrumpft die abgelegte Menge so weit, dass
// eine Verwechslung der beiden Kennzahlen nicht auffiele.
originalData := buildDeterministicData(3*1024*1024, 7)
runBackup(testInstance, engine, "statistik-1", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
EncryptionEnabled: true,
})
storedManifest, readError := testRepository.ReadManifest(context.Background(), "statistik-1")
if readError != nil {
testInstance.Fatalf("das Manifest ließ sich nicht lesen: %v", readError)
}
manifestStatistics := storedManifest.Statistics
if manifestStatistics.LogicalBytes != int64(len(originalData)) {
testInstance.Errorf("das Manifest weist %d Byte Ursprungsdaten aus, gesichert wurden %d",
manifestStatistics.LogicalBytes, len(originalData))
}
if manifestStatistics.ChunksWritten == 0 {
testInstance.Error("das Manifest weist null geschriebene Blöcke aus")
}
if manifestStatistics.StoredBytes == 0 {
testInstance.Error("das Manifest weist null abgelegte Byte aus")
}
// Die abgelegte Menge muss zur Zahl der Bloecke passen. Ein Manifest, das
// Bloecke zaehlt, aber keine Bytes, waere halb richtig — und halb richtige
// Kennzahlen sind die unangenehmsten.
if manifestStatistics.StoredBytes < manifestStatistics.ChunksWritten {
testInstance.Errorf("%d Blöcke ergeben %d Byte — das kann nicht sein",
manifestStatistics.ChunksWritten, manifestStatistics.StoredBytes)
}
}
// TestSecondBackupCountsDeduplication prueft die Zaehlung wiederverwendeter Bloecke.
//
// Ein zweiter Lauf ueber dieselben Daten legt nichts Neues ab. Traegt sein
// Manifest trotzdem eine Statistik von null, laesst sich der Nutzen der
// Deduplizierung aus dem Repository allein nicht mehr belegen.
func TestSecondBackupCountsDeduplication(testInstance *testing.T) {
engine, testRepository := newTestEngine(testInstance)
originalData := buildDeterministicData(2*1024*1024, 11)
runBackup(testInstance, engine, "dedup-1", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
})
runBackup(testInstance, engine, "dedup-2", originalData, BackupOptions{
CompressionLevel: CompressionBalanced,
})
secondManifest, readError := testRepository.ReadManifest(context.Background(), "dedup-2")
if readError != nil {
testInstance.Fatalf("das Manifest ließ sich nicht lesen: %v", readError)
}
secondStatistics := secondManifest.Statistics
if secondStatistics.ChunksDeduplicated == 0 {
testInstance.Error("der zweite Lauf weist keinen einzigen wiederverwendeten Block aus")
}
if secondStatistics.ChunksWritten != 0 {
testInstance.Errorf("der zweite Lauf schrieb %d Blöcke; erwartet wurden keine",
secondStatistics.ChunksWritten)
}
// Die verarbeitete Menge zaehlt trotzdem: Die Daten wurden gelesen und
// gehasht, nur nicht abgelegt.
if secondStatistics.LogicalBytes != int64(len(originalData)) {
testInstance.Errorf("der zweite Lauf weist %d Byte Ursprungsdaten aus, erwartet %d",
secondStatistics.LogicalBytes, len(originalData))
}
}