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

362 lines
13 KiB
Go

package repository
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// frozenRepositoryPath ist das eingefrorene Beispiel-Repository.
//
// Es liegt als echtes Verzeichnis im Quellbestand und wurde mit
// Repository-Formatversion 1 angelegt. Der Unterschied zu einem gewöhnlichen
// Rundlauftest ist derselbe wie beim Containerformat: Der schreibt und liest
// mit demselben Code und bliebe grün, wenn sich beide Seiten gemeinsam ändern.
const frozenRepositoryPath = "testdata/frozen-repository"
// TestRepositoryLayoutIsFrozen haelt das Repository-Protokoll fest (Phase 22).
//
// Die Verzeichnis- und Dateinamen sind kein Umsetzungsdetail, sondern der
// Vertrag: Ein Repository muss ohne Control Server und ohne Datenbank deutbar
// bleiben (`Attach → Discover Format → Scan Manifests → Rebuild Catalog →
// Restore`). Wer hier umbenennt, macht bestehende Repositories unauffindbar —
// die Daten liegen noch da und niemand findet sie mehr.
func TestRepositoryLayoutIsFrozen(testInstance *testing.T) {
frozenNames := map[string]string{
"directoryFormat": directoryFormat,
"directoryManifests": directoryManifests,
"directoryChunks": directoryChunks,
"directoryIndexes": directoryIndexes,
"directoryJournals": directoryJournals,
"directoryVerification": directoryVerification,
"directoryMetadata": directoryMetadata,
"directoryStaging": directoryStaging,
"fileDescriptor": fileDescriptor,
"fileCatalog": fileCatalog,
"fileLock": fileLock,
"manifestExtension": manifestExtension,
"journalExtension": journalExtension,
"retentionHoldExtension": retentionHoldExtension,
}
expectedNames := map[string]string{
"directoryFormat": "format",
"directoryManifests": "manifests",
"directoryChunks": "chunks",
"directoryIndexes": "indexes",
"directoryJournals": "journals",
"directoryVerification": "verification",
"directoryMetadata": "metadata",
"directoryStaging": "staging",
"fileDescriptor": "repository.json",
"fileCatalog": "catalog.json",
"fileLock": "repository.lock",
"manifestExtension": ".manifest.json",
"journalExtension": ".journal.json",
"retentionHoldExtension": ".hold.json",
}
for constantName, expectedValue := range expectedNames {
if frozenNames[constantName] != expectedValue {
testInstance.Errorf("%s heißt %q statt %q.\n"+
" Bestehende Repositories werden damit unauffindbar — die Daten liegen noch da.",
constantName, frozenNames[constantName], expectedValue)
}
}
if ManifestVersion != 1 {
testInstance.Errorf("die Manifestversion ist %d statt 1", ManifestVersion)
}
}
// TestFrozenRepositoryStaysReadable oeffnet das eingefrorene Repository.
//
// Der Test geht den Weg, den ein Notfall geht: anhaengen, Format erkennen,
// Manifeste lesen, Katalog **ohne Datenbank** neu aufbauen, Blöcke pruefen. Ist
// hier etwas kaputt, ist ein bestehendes Backup verloren — auch wenn jeder
// andere Test gruen bleibt.
func TestFrozenRepositoryStaysReadable(testInstance *testing.T) {
repositoryCopy := copyFrozenRepository(testInstance)
openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger())
if openError != nil {
testInstance.Fatalf("das eingefrorene Repository ließ sich nicht öffnen: %v\n"+
" Erzeugen mit: SYNCOVA_WRITE_FIXTURE=ja go test ./packages/repository "+
"-run TestWriteFrozenRepositoryFixture", openError)
}
defer func() { _ = openedRepository.Close() }()
repositoryDescriptor := openedRepository.Descriptor()
if repositoryDescriptor.FormatVersion != 1 {
testInstance.Errorf("die Formatversion des Repositorys ist %d statt 1",
repositoryDescriptor.FormatVersion)
}
if repositoryDescriptor.HashAlgorithm != HashAlgorithmSHA256 {
testInstance.Errorf("das Hashverfahren ist %q — die Chunk-Kennungen wären damit andere",
repositoryDescriptor.HashAlgorithm)
}
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("es liegen %d Backups im eingefrorenen Repository statt eines", len(catalogEntries))
}
storedManifest, manifestError := openedRepository.ReadManifest(context.Background(),
catalogEntries[0].BackupID)
if manifestError != nil {
testInstance.Fatalf("das Manifest ließ sich nicht lesen: %v", manifestError)
}
if storedManifest.ManifestVersion != 1 {
testInstance.Errorf("die Manifestversion ist %d", storedManifest.ManifestVersion)
}
if len(storedManifest.Entries) == 0 {
testInstance.Fatal("das eingefrorene Manifest enthält keine Objekte")
}
// Jeder Block muss noch da sein und seiner Kennung entsprechen. Ein Manifest
// allein belegt nur, dass jemand einmal etwas gesichert hat.
for _, chunkReference := range storedManifest.UniqueChunkReferences() {
storedChunk, chunkError := openedRepository.ReadStoredChunk(context.Background(),
chunkReference.Identifier, chunkReference.StoredDigest)
if chunkError != nil {
testInstance.Errorf("der Block %s ließ sich nicht lesen: %v", chunkReference.Identifier, chunkError)
continue
}
if len(storedChunk) == 0 {
testInstance.Errorf("der Block %s ist leer", chunkReference.Identifier)
}
}
}
// TestFrozenRepositoryRebuildsWithoutCatalog prueft die Kernzusage.
//
// „Bliebe das Repository nutzbar, wenn Control Server und Datenbank ersatzlos
// verschwinden?" — hier wird zusaetzlich der Katalog entfernt, also der letzte
// Beschleuniger. Was bleibt, sind die Manifeste, und aus ihnen allein muss sich
// alles wieder aufbauen lassen.
func TestFrozenRepositoryRebuildsWithoutCatalog(testInstance *testing.T) {
repositoryCopy := copyFrozenRepository(testInstance)
// Das ganze Verzeichnis entfernen, nicht nur die Datei: „Katalog verloren"
// heißt nicht immer „Datei gelöscht" (der Fund aus Phase 18).
if removeError := os.RemoveAll(filepath.Join(repositoryCopy, directoryIndexes)); removeError != nil {
testInstance.Fatalf("der Katalog ließ sich nicht entfernen: %v", removeError)
}
openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger())
if openError != nil {
testInstance.Fatalf("das Repository ließ sich ohne Katalog nicht öffnen: %v", openError)
}
defer func() { _ = openedRepository.Close() }()
rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(context.Background())
if rebuildError != nil {
testInstance.Fatalf("der Katalog ließ sich nicht neu aufbauen: %v", rebuildError)
}
if len(rebuiltCatalog.Entries) != 1 {
testInstance.Fatalf("der neu gebaute Katalog enthält %d Backups statt eines",
len(rebuiltCatalog.Entries))
}
}
// TestFrozenRepositoryPassesDeepScan prueft jeden Block gegen seine Pruefsumme.
func TestFrozenRepositoryPassesDeepScan(testInstance *testing.T) {
repositoryCopy := copyFrozenRepository(testInstance)
openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger())
if openError != nil {
testInstance.Fatalf("das eingefrorene Repository ließ sich nicht öffnen: %v", openError)
}
defer func() { _ = openedRepository.Close() }()
scanReport, scanError := openedRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
if scanError != nil {
testInstance.Fatalf("der Integritätslauf schlug fehl: %v", scanError)
}
if !scanReport.IsHealthy() {
testInstance.Errorf("der Integritätslauf meldet Befunde: %s", scanReport.Summary())
}
if scanReport.ChunksChecked != 2 {
testInstance.Errorf("es wurden %d Blöcke geprüft statt zwei", scanReport.ChunksChecked)
}
}
// copyFrozenRepository legt eine Arbeitskopie an.
//
// Das eingefrorene Repository selbst wird nie geöffnet: Ein Öffnen legt eine
// Sperrdatei an und schreibt womöglich den Katalog fort — der Vertrag änderte
// sich dann durch seine eigene Prüfung.
func copyFrozenRepository(testInstance *testing.T) string {
testInstance.Helper()
if _, statError := os.Stat(frozenRepositoryPath); statError != nil {
testInstance.Skipf("das eingefrorene Repository fehlt: %v", statError)
}
targetRoot := filepath.Join(testInstance.TempDir(), "frozen")
copyError := filepath.WalkDir(frozenRepositoryPath, func(currentPath string, directoryEntry os.DirEntry, walkError error) error {
if walkError != nil {
return walkError
}
relativePath, relativeError := filepath.Rel(frozenRepositoryPath, currentPath)
if relativeError != nil {
return relativeError
}
targetPath := filepath.Join(targetRoot, relativePath)
if directoryEntry.IsDir() {
return os.MkdirAll(targetPath, 0o700)
}
fileContent, readError := os.ReadFile(currentPath)
if readError != nil {
return readError
}
return os.WriteFile(targetPath, fileContent, 0o600)
})
if copyError != nil {
testInstance.Fatalf("die Arbeitskopie ließ sich nicht anlegen: %v", copyError)
}
// Leere Verzeichnisse überleben kein Git. Sie werden hier ergänzt, weil ein
// Repository sie erwartet — das ist kein Formatverstoß, sondern eine
// Eigenheit der Ablage im Quellbestand.
for _, requiredDirectory := range allRepositoryDirectories {
if makeError := os.MkdirAll(filepath.Join(targetRoot, requiredDirectory), 0o700); makeError != nil {
testInstance.Fatalf("das Verzeichnis %s ließ sich nicht anlegen: %v", requiredDirectory, makeError)
}
}
return targetRoot
}
// TestWriteFrozenRepositoryFixture erzeugt das eingefrorene Repository neu.
//
// Nur mit ausdruecklichem Schalter — es versehentlich mitlaufen zu lassen hiesse,
// den Vertrag jedes Mal auf den aktuellen Stand zu heben und damit genau die
// Pruefung abzuschalten, um die es geht.
func TestWriteFrozenRepositoryFixture(testInstance *testing.T) {
if os.Getenv("SYNCOVA_WRITE_FIXTURE") != "ja" {
testInstance.Skip("nur mit SYNCOVA_WRITE_FIXTURE=ja; sonst würde der eingefrorene Vertrag überschrieben")
}
if removeError := os.RemoveAll(frozenRepositoryPath); removeError != nil {
testInstance.Fatalf("das alte Fixture ließ sich nicht entfernen: %v", removeError)
}
createdRepository, createError := Create(context.Background(), frozenRepositoryPath, CreateOptions{
Name: "Eingefrorenes Beispiel (Phase 22)",
CreatedByVersion: "0.1.0-dev",
}, newTestLogger())
if createError != nil {
testInstance.Fatalf("das Repository ließ sich nicht anlegen: %v", createError)
}
writeSession, beginError := createdRepository.BeginBackup(context.Background(), "eingefroren-phase-22")
if beginError != nil {
testInstance.Fatalf("die Schreibsession ließ sich nicht öffnen: %v", beginError)
}
firstChunk := []byte("erster eingefrorener block der phase 22")
secondChunk := []byte("zweiter eingefrorener block der phase 22")
firstReference, _, firstError := writeSession.WriteChunk(context.Background(), firstChunk)
if firstError != nil {
testInstance.Fatalf("der erste Block ließ sich nicht schreiben: %v", firstError)
}
secondReference, _, secondError := writeSession.WriteChunk(context.Background(), secondChunk)
if secondError != nil {
testInstance.Fatalf("der zweite Block ließ sich nicht schreiben: %v", secondError)
}
backupManifest := Manifest{
BackupID: "eingefroren-phase-22",
ChainID: "kette-phase-22",
BackupType: BackupTypeFull,
ConsistencyLevel: ConsistencyCrash,
Source: SourceInformation{
SourceType: "filesystem",
SourceID: "/daten",
SourceName: "Eingefrorene Quelle",
},
Entries: []ManifestEntry{
{
Path: "daten/erste-datei.txt",
EntryType: "file",
SizeBytes: int64(len(firstChunk)),
Mode: "0644",
Chunks: []ChunkReference{firstReference},
},
{
Path: "daten/zweite-datei.txt",
EntryType: "file",
SizeBytes: int64(len(secondChunk)),
Mode: "0644",
Chunks: []ChunkReference{secondReference},
},
},
CreatedByVersion: "0.1.0-dev",
}
if commitError := writeSession.Commit(context.Background(), &backupManifest); commitError != nil {
testInstance.Fatalf("der Commit schlug fehl: %v", commitError)
}
if closeError := createdRepository.Close(); closeError != nil {
testInstance.Fatalf("das Repository ließ sich nicht schließen: %v", closeError)
}
// Die Sperrdatei gehört nicht in den Quellbestand: Sie beschreibt einen
// laufenden Zugriff, nicht das Format.
_ = os.Remove(filepath.Join(frozenRepositoryPath, fileLock))
writtenFiles := listFixtureFiles(testInstance, frozenRepositoryPath)
testInstance.Logf("eingefrorenes Repository geschrieben:\n %s", strings.Join(writtenFiles, "\n "))
}
// listFixtureFiles zaehlt die geschriebenen Dateien auf.
func listFixtureFiles(testInstance *testing.T, rootPath string) []string {
testInstance.Helper()
collectedFiles := make([]string, 0, 16)
_ = filepath.WalkDir(rootPath, func(currentPath string, directoryEntry os.DirEntry, walkError error) error {
if walkError != nil || directoryEntry.IsDir() {
return nil
}
relativePath, _ := filepath.Rel(rootPath, currentPath)
collectedFiles = append(collectedFiles, relativePath)
return nil
})
sort.Strings(collectedFiles)
return collectedFiles
}