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)) } }