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

607 lines
23 KiB
Go

package repository
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
)
// newTestLogger liefert einen Logger, der ins Nichts schreibt.
func newTestLogger() *slog.Logger {
return slog.New(slog.NewJSONHandler(io.Discard, nil))
}
// newTestRepository legt ein Repository in einem temporären Verzeichnis an.
//
// t.TempDir räumt das Verzeichnis nach dem Test automatisch weg.
func newTestRepository(testInstance *testing.T) *LocalRepository {
testInstance.Helper()
testRepository, createError := Create(context.Background(), testInstance.TempDir(), CreateOptions{
Name: "Test-Repository",
Kind: KindLocal,
CreatedByVersion: "test",
}, newTestLogger())
if createError != nil {
testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError)
}
testInstance.Cleanup(func() { _ = testRepository.Close() })
return testRepository
}
// writeTestBackup schreibt ein Backup mit den übergebenen Datenblöcken.
func writeTestBackup(testInstance *testing.T, testRepository *LocalRepository, backupID string, chunkContents [][]byte) *Manifest {
testInstance.Helper()
backupWriter, beginError := testRepository.BeginBackup(context.Background(), backupID)
if beginError != nil {
testInstance.Fatalf("die Schreibsession konnte nicht geöffnet werden: %v", beginError)
}
chunkReferences := make([]ChunkReference, 0, len(chunkContents))
var logicalOffset int64
for _, chunkContent := range chunkContents {
chunkReference, _, writeError := backupWriter.WriteChunk(context.Background(), chunkContent)
if writeError != nil {
testInstance.Fatalf("der Chunk konnte nicht geschrieben werden: %v", writeError)
}
chunkReference.LogicalOffset = logicalOffset
logicalOffset += chunkReference.LogicalLength
chunkReferences = append(chunkReferences, chunkReference)
}
manifest := &Manifest{
Source: SourceInformation{
SourceType: "filesystem",
SourceID: "quelle-1",
SourceName: "Testquelle",
},
Entries: []ManifestEntry{{
Path: "/daten/test.bin",
EntryType: "file",
SizeBytes: logicalOffset,
Chunks: chunkReferences,
}},
CreatedByVersion: "test",
}
if commitError := backupWriter.Commit(context.Background(), manifest); commitError != nil {
testInstance.Fatalf("das Backup konnte nicht abgeschlossen werden: %v", commitError)
}
return manifest
}
// ---------------------------------------------------------------------------
// Anlegen und Öffnen
// ---------------------------------------------------------------------------
func TestCreateProducesSelfDescribingRepository(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
// Alle Verzeichnisse des Formats müssen vorliegen.
for _, expectedDirectory := range allRepositoryDirectories {
directoryPath := filepath.Join(testRepository.RootPath(), expectedDirectory)
if _, statError := os.Stat(directoryPath); statError != nil {
testInstance.Errorf("das Verzeichnis %q fehlt: %v", expectedDirectory, statError)
}
}
descriptor := testRepository.Descriptor()
if descriptor.Identifier != FormatIdentifier {
testInstance.Errorf("die Kennung soll %q sein, war %q", FormatIdentifier, descriptor.Identifier)
}
// Ohne Repository-Kennung liesse sich ein Katalog nicht zuordnen.
if descriptor.RepositoryID == "" {
testInstance.Error("dem Repository fehlt seine Kennung")
}
}
func TestCreateRefusesToOverwriteExistingRepository(testInstance *testing.T) {
repositoryPath := testInstance.TempDir()
firstRepository, _ := Create(context.Background(), repositoryPath, CreateOptions{Name: "Erstes"}, newTestLogger())
_ = firstRepository.Close()
// Ein bestehendes Repository zu überschreiben wäre vollständiger Datenverlust.
_, secondError := Create(context.Background(), repositoryPath, CreateOptions{Name: "Zweites"}, newTestLogger())
if !errors.Is(secondError, ErrRepositoryExists) {
testInstance.Fatalf("ein bestehendes Repository darf nicht überschrieben werden, war: %v", secondError)
}
}
func TestOpenRejectsDirectoryWithoutRepository(testInstance *testing.T) {
_, openError := Open(context.Background(), testInstance.TempDir(), OpenOptions{}, newTestLogger())
// Ein beliebiges Verzeichnis darf nicht als Repository durchgehen.
if !errors.Is(openError, ErrNotARepository) {
testInstance.Fatalf("erwartet wurde ErrNotARepository, war: %v", openError)
}
}
func TestOpenRejectsNewerFormatVersion(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
repositoryPath := testRepository.RootPath()
_ = testRepository.Close()
// Der Descriptor wird auf eine künftige Formatversion gesetzt.
descriptor := testRepository.Descriptor()
descriptor.FormatVersion = FormatVersion + 1
encodedDescriptor, _ := encodeDescriptor(descriptor)
descriptorPath := filepath.Join(repositoryPath, directoryFormat, fileDescriptor)
if writeError := os.WriteFile(descriptorPath, encodedDescriptor, dataFilePermissions); writeError != nil {
testInstance.Fatalf("der Descriptor konnte nicht geändert werden: %v", writeError)
}
// Eine ältere Programmversion darf ein neueres Format nicht anfassen.
_, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger())
if !errors.Is(openError, ErrUnsupportedFormat) {
testInstance.Fatalf("ein neueres Format muss abgelehnt werden, war: %v", openError)
}
}
// ---------------------------------------------------------------------------
// Chunks
// ---------------------------------------------------------------------------
func TestWriteAndReadChunk(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
originalData := []byte("Dies sind Testdaten für einen Chunk.")
chunkIdentifier, wasNew, writeError := testRepository.writeChunk(context.Background(), originalData)
if writeError != nil {
testInstance.Fatalf("der Chunk konnte nicht geschrieben werden: %v", writeError)
}
if !wasNew {
testInstance.Error("der erste Chunk soll als neu gelten")
}
readData, readError := testRepository.ReadChunk(context.Background(), chunkIdentifier)
if readError != nil {
testInstance.Fatalf("der Chunk konnte nicht gelesen werden: %v", readError)
}
if !bytes.Equal(readData, originalData) {
testInstance.Fatalf("der gelesene Inhalt weicht ab: %q", readData)
}
}
func TestIdenticalContentIsStoredOnlyOnce(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
sameData := []byte("Derselbe Inhalt zweimal geschrieben.")
firstIdentifier, firstWasNew, _ := testRepository.writeChunk(context.Background(), sameData)
secondIdentifier, secondWasNew, _ := testRepository.writeChunk(context.Background(), sameData)
// Inhaltsadressierung bedeutet: gleicher Inhalt, gleiche Kennung.
if firstIdentifier != secondIdentifier {
testInstance.Fatal("gleicher Inhalt muss dieselbe Kennung ergeben")
}
if !firstWasNew {
testInstance.Error("der erste Schreibvorgang soll als neu gelten")
}
// Der zweite Vorgang darf nichts erneut schreiben - das ist die Deduplizierung.
if secondWasNew {
testInstance.Error("der zweite Schreibvorgang darf nicht als neu gelten")
}
}
func TestReadChunkDetectsCorruption(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
originalData := []byte("Dieser Inhalt wird gleich manipuliert.")
chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData)
// Ein einzelnes verändertes Byte simuliert einen Bitfehler auf dem Datenträger.
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
if chmodError := os.Chmod(chunkFilePath, 0o600); chmodError != nil {
testInstance.Fatalf("die Dateirechte konnten nicht geändert werden: %v", chmodError)
}
corruptedData := bytes.Clone(originalData)
corruptedData[0] ^= 0xff
if writeError := os.WriteFile(chunkFilePath, corruptedData, 0o600); writeError != nil {
testInstance.Fatalf("der Chunk konnte nicht manipuliert werden: %v", writeError)
}
// Ein beschädigter Chunk darf niemals unbemerkt ausgeliefert werden.
_, readError := testRepository.ReadChunk(context.Background(), chunkIdentifier)
if !errors.Is(readError, ErrChunkCorrupted) {
testInstance.Fatalf("die Beschädigung muss erkannt werden, war: %v", readError)
}
}
func TestReadChunkReportsMissingChunk(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
// Eine wohlgeformte, aber nicht vorhandene Kennung.
missingIdentifier := "0000000000000000000000000000000000000000000000000000000000000000"
_, readError := testRepository.ReadChunk(context.Background(), missingIdentifier)
if !errors.Is(readError, ErrChunkNotFound) {
testInstance.Fatalf("ein fehlender Chunk muss gemeldet werden, war: %v", readError)
}
}
func TestChunkIdentifierIsValidatedAgainstPathTraversal(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
maliciousIdentifiers := []string{
"../../../etc/passwd",
"..",
"/absolute/pfad",
"kurz",
"GROSSBUCHSTABEN000000000000000000000000000000000000000000000000",
"zzzz000000000000000000000000000000000000000000000000000000000000",
}
for _, maliciousIdentifier := range maliciousIdentifiers {
// Eine manipulierte Kennung darf niemals aus dem Repository herausführen.
if _, pathError := testRepository.chunkPath(maliciousIdentifier); pathError == nil {
testInstance.Errorf("die Kennung %q wurde akzeptiert", maliciousIdentifier)
}
}
}
func TestOpenChunkVerifiesWhileStreaming(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
originalData := bytes.Repeat([]byte("Streaming-Daten. "), 1000)
chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData)
chunkStream, openError := testRepository.OpenChunk(context.Background(), chunkIdentifier)
if openError != nil {
testInstance.Fatalf("der Chunk konnte nicht geöffnet werden: %v", openError)
}
defer func() { _ = chunkStream.Close() }()
streamedData, readError := io.ReadAll(chunkStream)
if readError != nil {
testInstance.Fatalf("der Datenstrom konnte nicht gelesen werden: %v", readError)
}
if !bytes.Equal(streamedData, originalData) {
testInstance.Fatal("der gestreamte Inhalt weicht ab")
}
}
func TestOpenChunkDetectsCorruptionAtEndOfStream(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
originalData := bytes.Repeat([]byte("A"), 4096)
chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData)
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
_ = os.Chmod(chunkFilePath, 0o600)
corruptedData := bytes.Clone(originalData)
corruptedData[2048] = 'B'
_ = os.WriteFile(chunkFilePath, corruptedData, 0o600)
chunkStream, _ := testRepository.OpenChunk(context.Background(), chunkIdentifier)
defer func() { _ = chunkStream.Close() }()
// Beim Streamen fällt die Beschädigung spätestens am Ende auf.
_, readError := io.ReadAll(chunkStream)
if !errors.Is(readError, ErrChunkCorrupted) {
testInstance.Fatalf("die Beschädigung muss beim Streamen auffallen, war: %v", readError)
}
}
// ---------------------------------------------------------------------------
// Commit-Protokoll
// ---------------------------------------------------------------------------
func TestCommitMakesBackupVisible(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{
[]byte("Erster Block"),
[]byte("Zweiter Block"),
})
loadedManifest, readError := testRepository.ReadManifest(context.Background(), "backup-1")
if readError != nil {
testInstance.Fatalf("das Manifest konnte nicht gelesen werden: %v", readError)
}
if !loadedManifest.Complete {
testInstance.Error("das Manifest muss als abgeschlossen gekennzeichnet sein")
}
if loadedManifest.Statistics.ChunksWritten != 2 {
testInstance.Errorf("es sollen 2 Chunks vermerkt sein, waren %d", loadedManifest.Statistics.ChunksWritten)
}
}
func TestAbortLeavesNoVisibleBackup(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
backupWriter, beginError := testRepository.BeginBackup(context.Background(), "abgebrochen")
if beginError != nil {
testInstance.Fatalf("die Schreibsession konnte nicht geöffnet werden: %v", beginError)
}
_, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten eines abgebrochenen Laufs"))
if abortError := backupWriter.Abort(context.Background()); abortError != nil {
testInstance.Fatalf("der Abbruch schlug fehl: %v", abortError)
}
// Ohne Commit darf kein Backup sichtbar sein - ein halbes Backup wäre
// schlimmer als gar keines.
_, readError := testRepository.ReadManifest(context.Background(), "abgebrochen")
if !errors.Is(readError, ErrBackupNotFound) {
testInstance.Fatalf("ein abgebrochener Lauf darf kein Backup hinterlassen, war: %v", readError)
}
}
func TestManifestWithoutCompletionMarkerIsRejected(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "unvollstaendig", [][]byte{[]byte("Daten")})
// Der Abschlussvermerk wird entfernt.
manifestFilePath := testRepository.manifestPath("unvollstaendig")
rawManifest, _ := os.ReadFile(manifestFilePath)
manifest, _ := decodeManifest(rawManifest)
manifest.Complete = false
tamperedManifest, _ := encodeManifest(manifest)
_ = os.Chmod(manifestFilePath, 0o600)
_ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600)
// Ohne Abschlussvermerk gilt das Backup als unvollständig.
_, readError := testRepository.ReadManifest(context.Background(), "unvollstaendig")
if !errors.Is(readError, ErrBackupIncomplete) {
testInstance.Fatalf("ein Backup ohne Abschlussvermerk muss abgelehnt werden, war: %v", readError)
}
}
func TestTamperedManifestIsDetected(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "manipuliert", [][]byte{[]byte("Daten")})
manifestFilePath := testRepository.manifestPath("manipuliert")
rawManifest, _ := os.ReadFile(manifestFilePath)
manifest, _ := decodeManifest(rawManifest)
// Die Kennzahl wird verfälscht, die Prüfsumme bleibt die alte.
manifest.Statistics.LogicalBytes = 999999
tamperedManifest, _ := encodeManifest(manifest)
_ = os.Chmod(manifestFilePath, 0o600)
_ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600)
_, readError := testRepository.ReadManifest(context.Background(), "manipuliert")
if !errors.Is(readError, ErrManifestCorrupted) {
testInstance.Fatalf("ein verfälschtes Manifest muss erkannt werden, war: %v", readError)
}
}
func TestExistingBackupIsNotOverwritten(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Original")})
// Ein zweites Backup gleicher Kennung würde das erste vernichten.
_, beginError := testRepository.BeginBackup(context.Background(), "backup-1")
if beginError == nil {
testInstance.Fatal("eine bereits vergebene Backup-Kennung muss abgelehnt werden")
}
}
func TestWriteAfterCommitIsRejected(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
backupWriter, _ := testRepository.BeginBackup(context.Background(), "geschlossen")
_, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten"))
_ = backupWriter.Commit(context.Background(), &Manifest{Source: SourceInformation{SourceType: "filesystem"}})
// Nach dem Abschluss darf nichts mehr hinzukommen: das Manifest ist versiegelt.
_, _, writeError := backupWriter.WriteChunk(context.Background(), []byte("Nachzügler"))
if !errors.Is(writeError, ErrSessionClosed) {
testInstance.Fatalf("ein Schreibvorgang nach dem Abschluss muss abgelehnt werden, war: %v", writeError)
}
}
func TestDeduplicationAcrossBackups(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
sharedContent := []byte("Dieser Block kommt in beiden Backups vor.")
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{sharedContent, []byte("Nur im ersten")})
backupWriter, _ := testRepository.BeginBackup(context.Background(), "backup-2")
_, firstWasNew, _ := backupWriter.WriteChunk(context.Background(), sharedContent)
_, secondWasNew, _ := backupWriter.WriteChunk(context.Background(), []byte("Nur im zweiten"))
// Der gemeinsame Block darf nicht erneut abgelegt werden.
if firstWasNew {
testInstance.Error("der bereits vorhandene Block darf nicht erneut geschrieben werden")
}
if !secondWasNew {
testInstance.Error("der neue Block muss geschrieben werden")
}
sessionStatistics := backupWriter.Statistics()
if sessionStatistics.ChunksDeduplicated != 1 {
testInstance.Errorf("es soll 1 Chunk dedupliziert sein, waren %d", sessionStatistics.ChunksDeduplicated)
}
_ = backupWriter.Abort(context.Background())
}
// ---------------------------------------------------------------------------
// Katalog
// ---------------------------------------------------------------------------
func TestCatalogListsCommittedBackups(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")})
catalogEntries, listError := testRepository.ListBackups(context.Background())
if listError != nil {
testInstance.Fatalf("der Katalog konnte nicht gelesen werden: %v", listError)
}
if len(catalogEntries) != 2 {
testInstance.Fatalf("es sollen 2 Backups im Katalog stehen, waren %d", len(catalogEntries))
}
}
func TestRebuildCatalogWithoutAnyDatabase(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")})
writeTestBackup(testInstance, testRepository, "backup-3", [][]byte{[]byte("Drei")})
// Der Katalog wird vollständig gelöscht - wie nach dem Verlust des Control Servers.
if removeError := os.Remove(testRepository.catalogPath()); removeError != nil {
testInstance.Fatalf("der Katalog konnte nicht gelöscht werden: %v", removeError)
}
rebuiltCatalog, rebuildError := testRepository.RebuildCatalog(context.Background())
if rebuildError != nil {
testInstance.Fatalf("der Katalog konnte nicht wiederaufgebaut werden: %v", rebuildError)
}
if len(rebuiltCatalog.Entries) != 3 {
testInstance.Fatalf("der wiederaufgebaute Katalog soll 3 Backups enthalten, waren %d", len(rebuiltCatalog.Entries))
}
if !rebuiltCatalog.RebuiltFromManifests {
testInstance.Error("der Katalog soll als wiederaufgebaut gekennzeichnet sein")
}
}
func TestCorruptedCatalogIsRebuiltAutomatically(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
// Der Katalog wird durch Unsinn ersetzt.
if writeError := os.WriteFile(testRepository.catalogPath(), []byte("kein gültiges JSON"), 0o600); writeError != nil {
testInstance.Fatalf("der Katalog konnte nicht manipuliert werden: %v", writeError)
}
// Ein unlesbarer Katalog ist kein Datenverlust: er wird neu erzeugt.
catalogEntries, listError := testRepository.ListBackups(context.Background())
if listError != nil {
testInstance.Fatalf("der Katalog wurde nicht wiederaufgebaut: %v", listError)
}
if len(catalogEntries) != 1 {
testInstance.Fatalf("es soll 1 Backup gefunden werden, waren %d", len(catalogEntries))
}
}
func TestRebuildSkipsIncompleteBackups(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "vollstaendig", [][]byte{[]byte("Gut")})
writeTestBackup(testInstance, testRepository, "unvollstaendig", [][]byte{[]byte("Schlecht")})
// Dem zweiten Backup wird der Abschlussvermerk entzogen.
manifestFilePath := testRepository.manifestPath("unvollstaendig")
rawManifest, _ := os.ReadFile(manifestFilePath)
manifest, _ := decodeManifest(rawManifest)
manifest.Complete = false
tamperedManifest, _ := encodeManifest(manifest)
_ = os.Chmod(manifestFilePath, 0o600)
_ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600)
rebuiltCatalog, _ := testRepository.RebuildCatalog(context.Background())
// Ein unvollständiges Backup darf nicht als wiederherstellbar erscheinen.
if len(rebuiltCatalog.Entries) != 1 {
testInstance.Fatalf("nur das vollständige Backup gehört in den Katalog, es waren %d", len(rebuiltCatalog.Entries))
}
if rebuiltCatalog.Entries[0].BackupID != "vollstaendig" {
testInstance.Errorf("das falsche Backup wurde aufgenommen: %s", rebuiltCatalog.Entries[0].BackupID)
}
}
func TestCatalogOfForeignRepositoryIsRejected(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
// Ein Katalog mit fremder Repository-Kennung wird untergeschoben.
foreignCatalog := &Catalog{
CatalogVersion: CatalogVersion,
RepositoryID: "ein-fremdes-repository",
Entries: []CatalogEntry{
{BackupID: "erfundenes-backup"},
},
GeneratedAt: time.Now().UTC(),
}
encodedCatalog, _ := encodeCatalog(foreignCatalog)
_ = os.WriteFile(testRepository.catalogPath(), encodedCatalog, 0o600)
catalogEntries, _ := testRepository.ListBackups(context.Background())
// Der fremde Katalog darf nicht übernommen werden.
if len(catalogEntries) != 1 || catalogEntries[0].BackupID != "backup-1" {
testInstance.Fatalf("der fremde Katalog wurde übernommen: %+v", catalogEntries)
}
}
// TestRebuildAfterCatalogDirectoryIsGone prueft den Verlust des ganzen Verzeichnisses.
//
// „Katalog verloren" (Szenario C der Phase 18) heisst nicht immer „Datei
// geloescht": Bei einem Teilausfall des Dateisystems oder nach einem beherzten
// Aufraeumen fehlt das ganze Verzeichnis. Bis zum Nachweis der Phase 18
// scheiterte ausgerechnet der Neuaufbau, der den Verlust beheben soll — mit
// einer Meldung ueber eine temporaere Datei, die nichts ueber das eigentliche
// Problem sagte.
//
// Die bestehenden Tests loeschten stets nur die Katalogdatei und blieben
// deshalb gruen.
func TestRebuildAfterCatalogDirectoryIsGone(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")})
// Nicht nur die Datei — das ganze Verzeichnis.
if removeError := os.RemoveAll(filepath.Dir(testRepository.catalogPath())); removeError != nil {
testInstance.Fatalf("das Katalogverzeichnis konnte nicht gelöscht werden: %v", removeError)
}
catalogEntries, listError := testRepository.ListBackups(context.Background())
if listError != nil {
testInstance.Fatalf("der Katalog wurde nicht wiederaufgebaut: %v", listError)
}
if len(catalogEntries) != 2 {
testInstance.Fatalf("es sollen 2 Backups gefunden werden, waren %d", len(catalogEntries))
}
// Und er muss danach wieder auf der Platte liegen: Ein Katalog, der bei
// jedem Zugriff neu entsteht, macht aus einem Beschleuniger eine Bremse.
if _, statError := os.Stat(testRepository.catalogPath()); statError != nil {
testInstance.Errorf("der Katalog wurde nicht wieder abgelegt: %v", statError)
}
}