syncova-backup/packages/repository/portable_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

279 lines
11 KiB
Go

package repository
import (
"bytes"
"context"
"errors"
"os"
"testing"
"github.com/syncova/syncova/packages/backupformat"
)
func TestExportAndImportRoundTrip(testInstance *testing.T) {
sourceRepository := newTestRepository(testInstance)
originalManifest := writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{
[]byte("Erster Block"),
[]byte("Zweiter Block"),
[]byte("Dritter Block"),
})
// Export in einen portablen Container.
var containerBuffer bytes.Buffer
exportedBytes, exportError := sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
if exportError != nil {
testInstance.Fatalf("der Export schlug fehl: %v", exportError)
}
if exportedBytes == 0 {
testInstance.Fatal("der Container ist leer")
}
// Import in ein völlig anderes Repository - so wie eine Übertragung an
// einen zweiten Standort abliefe.
targetRepository := newTestRepository(testInstance)
importResult, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes()))
if importError != nil {
testInstance.Fatalf("der Import schlug fehl: %v", importError)
}
if importResult.BackupID != "backup-1" {
testInstance.Errorf("die Backup-Kennung weicht ab: %q", importResult.BackupID)
}
if importResult.ChunksImported != 3 {
testInstance.Errorf("es sollen 3 Chunks übernommen werden, waren %d", importResult.ChunksImported)
}
// Das eingelesene Manifest muss dem ursprünglichen entsprechen.
importedManifest, readError := targetRepository.ReadManifest(context.Background(), "backup-1")
if readError != nil {
testInstance.Fatalf("das eingelesene Manifest ist nicht lesbar: %v", readError)
}
if importedManifest.ContentHash != originalManifest.ContentHash {
testInstance.Error("die Prüfsumme des Manifests hat die Übertragung nicht überstanden")
}
// Die Daten selbst müssen bitgenau angekommen sein.
for _, chunkReference := range originalManifest.Entries[0].Chunks {
originalData, _ := sourceRepository.ReadChunk(context.Background(), chunkReference.Identifier)
importedData, chunkError := targetRepository.ReadChunk(context.Background(), chunkReference.Identifier)
if chunkError != nil {
testInstance.Fatalf("ein Chunk fehlt nach dem Import: %v", chunkError)
}
if !bytes.Equal(originalData, importedData) {
testInstance.Fatal("der Inhalt eines Chunks weicht nach der Übertragung ab")
}
}
// Das Backup muss im Zielkatalog auftauchen.
catalogEntries, _ := targetRepository.ListBackups(context.Background())
if len(catalogEntries) != 1 {
testInstance.Fatalf("das Ziel-Repository soll 1 Backup führen, waren %d", len(catalogEntries))
}
}
func TestImportDeduplicatesAgainstExistingChunks(testInstance *testing.T) {
sourceRepository := newTestRepository(testInstance)
sharedContent := []byte("Dieser Block existiert bereits im Ziel.")
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{sharedContent, []byte("Nur hier")})
var containerBuffer bytes.Buffer
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
// Das Ziel kennt einen der Blöcke bereits.
targetRepository := newTestRepository(testInstance)
_, _, _ = targetRepository.writeChunk(context.Background(), sharedContent)
importResult, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes()))
if importError != nil {
testInstance.Fatalf("der Import schlug fehl: %v", importError)
}
// Ein bereits vorhandener Block wird nicht erneut geschrieben.
if importResult.ChunksAlreadyPresent != 1 {
testInstance.Errorf("es soll 1 Block bereits vorhanden sein, waren %d", importResult.ChunksAlreadyPresent)
}
if importResult.ChunksImported != 1 {
testInstance.Errorf("es soll 1 Block übernommen werden, waren %d", importResult.ChunksImported)
}
}
func TestImportRejectsTruncatedContainer(testInstance *testing.T) {
sourceRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{[]byte("Daten")})
var containerBuffer bytes.Buffer
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
// Eine abgebrochene Übertragung liefert einen unvollständigen Container.
containerBytes := containerBuffer.Bytes()
truncatedContainer := containerBytes[:len(containerBytes)-30]
targetRepository := newTestRepository(testInstance)
_, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(truncatedContainer))
if importError == nil {
testInstance.Fatal("ein abgeschnittener Container darf nicht eingelesen werden")
}
// Es darf kein sichtbares Backup entstanden sein.
if _, readError := targetRepository.ReadManifest(context.Background(), "backup-1"); !errors.Is(readError, ErrBackupNotFound) {
testInstance.Fatalf("ein unvollständiger Import darf kein Backup hinterlassen, war: %v", readError)
}
}
func TestImportRejectsCorruptedContainer(testInstance *testing.T) {
sourceRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{[]byte("Originaldaten")})
var containerBuffer bytes.Buffer
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
// Ein Übertragungsfehler verfälscht ein Byte im Datenbereich.
tamperedContainer := bytes.Clone(containerBuffer.Bytes())
dataPosition := bytes.Index(tamperedContainer, []byte("Originaldaten"))
if dataPosition < 0 {
testInstance.Fatal("der Datenbereich wurde nicht gefunden")
}
tamperedContainer[dataPosition] = 'X'
targetRepository := newTestRepository(testInstance)
_, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(tamperedContainer))
if importError == nil {
testInstance.Fatal("ein verfälschter Container darf nicht eingelesen werden")
}
if _, readError := targetRepository.ReadManifest(context.Background(), "backup-1"); !errors.Is(readError, ErrBackupNotFound) {
testInstance.Fatal("ein fehlgeschlagener Import darf kein Backup hinterlassen")
}
}
func TestExportRefusesWhenChunkIsMissing(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
// Ein Chunk verschwindet vom Datenträger.
chunkFilePath, _ := testRepository.chunkPath(manifest.Entries[0].Chunks[0].Identifier)
_ = os.Remove(chunkFilePath)
// Ein Container ohne alle Blöcke wäre unbrauchbar und darf nicht entstehen.
var containerBuffer bytes.Buffer
_, exportError := testRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
if !errors.Is(exportError, ErrChunkNotFound) {
testInstance.Fatalf("der Export muss bei fehlendem Chunk abbrechen, war: %v", exportError)
}
}
func TestExportRefusesWhenChunkIsCorrupted(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Originaldaten")})
// Ein Block auf dem Datenträger ist beschädigt.
chunkFilePath, _ := testRepository.chunkPath(manifest.Entries[0].Chunks[0].Identifier)
_ = os.Chmod(chunkFilePath, 0o600)
_ = os.WriteFile(chunkFilePath, []byte("beschaedigt!!"), 0o600)
var containerBuffer bytes.Buffer
_, exportError := testRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
// Ein beschädigter Block darf nicht in einen Container gelangen: der
// Empfänger hielte ihn sonst für gültige Daten.
if !errors.Is(exportError, ErrChunkCorrupted) {
testInstance.Fatalf("der Export muss bei beschädigtem Chunk abbrechen, war: %v", exportError)
}
}
func TestImportRejectsDuplicateBackupIdentifier(testInstance *testing.T) {
sourceRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{[]byte("Daten")})
var containerBuffer bytes.Buffer
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
targetRepository := newTestRepository(testInstance)
// Der erste Import gelingt.
if _, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes())); importError != nil {
testInstance.Fatalf("der erste Import schlug fehl: %v", importError)
}
// Der zweite würde ein bestehendes Backup überschreiben.
if _, importError := targetRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes())); importError == nil {
testInstance.Fatal("ein bereits vorhandenes Backup darf nicht überschrieben werden")
}
}
func TestImportRefusesOnReadOnlyRepository(testInstance *testing.T) {
sourceRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, sourceRepository, "backup-1", [][]byte{[]byte("Daten")})
var containerBuffer bytes.Buffer
_, _ = sourceRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
targetRepository := newTestRepository(testInstance)
targetPath := targetRepository.RootPath()
_ = targetRepository.Close()
readOnlyRepository, _ := Open(context.Background(), targetPath, OpenOptions{ReadOnly: true}, newTestLogger())
defer func() { _ = readOnlyRepository.Close() }()
if _, importError := readOnlyRepository.ImportBackup(context.Background(), bytes.NewReader(containerBuffer.Bytes())); importError == nil {
testInstance.Fatal("ein nur lesend geöffnetes Repository darf nichts aufnehmen")
}
}
func TestExportedContainerCarriesChainInformation(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeChainBackup(testInstance, testRepository, "voll", "kette-1", "", BackupTypeFull)
writeChainBackup(testInstance, testRepository, "inkrement", "kette-1", "voll", BackupTypeIncremental)
var containerBuffer bytes.Buffer
_, _ = testRepository.ExportBackup(context.Background(), "inkrement", &containerBuffer, "test")
containerReader, readerError := backupformat.NewReader(bytes.NewReader(containerBuffer.Bytes()))
if readerError != nil {
testInstance.Fatalf("der Container konnte nicht gelesen werden: %v", readerError)
}
containerHeader := containerReader.Header()
// Ohne Kettenangaben liesse sich eine Kette aus einzelnen Containern nicht
// wieder zusammensetzen.
if containerHeader.ChainID != "kette-1" {
testInstance.Errorf("die Kettenkennung weicht ab: %q", containerHeader.ChainID)
}
if containerHeader.ParentBackupID != "voll" {
testInstance.Errorf("das Elternbackup weicht ab: %q", containerHeader.ParentBackupID)
}
}
func TestExportedContainerNamesSourceRepository(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
var containerBuffer bytes.Buffer
_, _ = testRepository.ExportBackup(context.Background(), "backup-1", &containerBuffer, "test")
containerReader, _ := backupformat.NewReader(bytes.NewReader(containerBuffer.Bytes()))
// Die Herkunft muss nachvollziehbar bleiben.
if containerReader.Header().RepositoryID != testRepository.Descriptor().RepositoryID {
testInstance.Error("der Container benennt sein Ursprungs-Repository nicht")
}
}