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

624 lines
26 KiB
Go

package repository
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// ---------------------------------------------------------------------------
// Integritätslauf
// ---------------------------------------------------------------------------
func TestScanReportsHealthyRepository(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins"), []byte("Zwei")})
writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Drei")})
scanReport, scanError := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
if scanError != nil {
testInstance.Fatalf("der Integritätslauf schlug fehl: %v", scanError)
}
if !scanReport.IsHealthy() {
testInstance.Fatalf("das Repository soll einwandfrei sein: %s", scanReport.Summary())
}
if scanReport.BackupsChecked != 2 || scanReport.BackupsHealthy != 2 {
testInstance.Errorf("es sollen 2 Backups geprüft und einwandfrei sein, waren %d/%d",
scanReport.BackupsHealthy, scanReport.BackupsChecked)
}
}
func TestScanDetectsCorruptedChunk(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Wird beschädigt")})
// Ein Chunk wird auf dem Datenträger verfälscht.
chunkIdentifier := manifest.Entries[0].Chunks[0].Identifier
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
_ = os.Chmod(chunkFilePath, 0o600)
_ = os.WriteFile(chunkFilePath, []byte("voellig andere daten"), 0o600)
scanReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
if scanReport.IsHealthy() {
testInstance.Fatal("ein beschädigtes Repository darf nicht als einwandfrei gelten")
}
if scanReport.CorruptedChunks != 1 {
testInstance.Errorf("es soll 1 beschädigter Chunk gemeldet werden, waren %d", scanReport.CorruptedChunks)
}
// Das betroffene Backup muss namentlich benannt werden.
if len(scanReport.AffectedBackupIDs) != 1 || scanReport.AffectedBackupIDs[0] != "backup-1" {
testInstance.Errorf("das betroffene Backup fehlt im Bericht: %v", scanReport.AffectedBackupIDs)
}
// Die Zusammenfassung darf das Problem nicht beschönigen.
if !strings.Contains(scanReport.Summary(), "NICHT vollständig") {
testInstance.Errorf("die Zusammenfassung ist zu harmlos: %s", scanReport.Summary())
}
}
func TestScanDetectsMissingChunk(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Wird geloescht")})
chunkIdentifier := manifest.Entries[0].Chunks[0].Identifier
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
if removeError := os.Remove(chunkFilePath); removeError != nil {
testInstance.Fatalf("der Chunk konnte nicht entfernt werden: %v", removeError)
}
scanReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
if scanReport.MissingChunks != 1 {
testInstance.Errorf("es soll 1 fehlender Chunk gemeldet werden, waren %d", scanReport.MissingChunks)
}
if scanReport.IsHealthy() {
testInstance.Fatal("ein Backup mit fehlendem Chunk darf nicht als einwandfrei gelten")
}
}
func TestScanWithoutContentVerificationOnlyChecksPresence(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Inhalt")})
// Der Inhalt wird verfälscht, die Datei bleibt aber vorhanden.
chunkIdentifier := manifest.Entries[0].Chunks[0].Identifier
chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier)
_ = os.Chmod(chunkFilePath, 0o600)
_ = os.WriteFile(chunkFilePath, []byte("andere daten"), 0o600)
// Der schnelle Lauf prüft nur die Anwesenheit und findet die Beschädigung
// erwartungsgemäß nicht.
quickReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: false})
if quickReport.CorruptedChunks != 0 {
testInstance.Error("der schnelle Lauf soll keine Inhaltsprüfung vornehmen")
}
// Der vollständige Lauf findet sie.
fullReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
if fullReport.CorruptedChunks != 1 {
testInstance.Errorf("der vollständige Lauf soll die Beschädigung finden, waren %d", fullReport.CorruptedChunks)
}
}
func TestScanFindsOrphanedChunks(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Benutzt")})
// Ein Chunk ohne zugehöriges Backup, wie er nach einem Abbruch zurückbleibt.
_, _, _ = testRepository.writeChunk(context.Background(), []byte("Von niemandem benutzt"))
scanReport, _ := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true})
if scanReport.OrphanedChunks != 1 {
testInstance.Errorf("es soll 1 verwaister Chunk gemeldet werden, waren %d", scanReport.OrphanedChunks)
}
// Ein verwaister Chunk ist kein Datenverlust, das Repository bleibt gesund.
if !scanReport.IsHealthy() {
testInstance.Error("verwaiste Chunks dürfen das Repository nicht als beschädigt gelten lassen")
}
}
func TestScanReportIsPersistedInRepository(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")})
if _, scanError := testRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true}); scanError != nil {
testInstance.Fatalf("der Integritätslauf schlug fehl: %v", scanError)
}
// Der Bericht gehört ins Repository, damit nach einem Wiederaufbau
// erkennbar bleibt, wann zuletzt geprüft wurde.
verificationDirectory := filepath.Join(testRepository.RootPath(), directoryVerification)
directoryEntries, readError := os.ReadDir(verificationDirectory)
if readError != nil {
testInstance.Fatalf("das Prüfverzeichnis konnte nicht gelesen werden: %v", readError)
}
if len(directoryEntries) == 0 {
testInstance.Fatal("es wurde kein Prüfbericht abgelegt")
}
}
// ---------------------------------------------------------------------------
// Bereinigung
// ---------------------------------------------------------------------------
func TestPruneRemovesOnlyOrphanedChunks(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
manifest := writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Benutzt")})
usedIdentifier := manifest.Entries[0].Chunks[0].Identifier
orphanedIdentifier, _, _ := testRepository.writeChunk(context.Background(), []byte("Verwaist"))
removedCount, _, pruneError := testRepository.PruneOrphanedChunks(context.Background(), false)
if pruneError != nil {
testInstance.Fatalf("die Bereinigung schlug fehl: %v", pruneError)
}
if removedCount != 1 {
testInstance.Errorf("es soll 1 Chunk entfernt werden, waren %d", removedCount)
}
// Der benutzte Chunk muss erhalten bleiben.
if _, readError := testRepository.ReadChunk(context.Background(), usedIdentifier); readError != nil {
testInstance.Fatalf("ein benutzter Chunk wurde entfernt: %v", readError)
}
// Der verwaiste ist weg.
if _, readError := testRepository.ReadChunk(context.Background(), orphanedIdentifier); !errors.Is(readError, ErrChunkNotFound) {
testInstance.Error("der verwaiste Chunk wurde nicht entfernt")
}
}
func TestPruneDryRunChangesNothing(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
orphanedIdentifier, _, _ := testRepository.writeChunk(context.Background(), []byte("Verwaist"))
removedCount, _, _ := testRepository.PruneOrphanedChunks(context.Background(), true)
if removedCount != 1 {
testInstance.Errorf("die Simulation soll 1 Chunk melden, waren %d", removedCount)
}
// In der Simulation darf nichts verschwinden.
if _, readError := testRepository.ReadChunk(context.Background(), orphanedIdentifier); readError != nil {
testInstance.Fatalf("die Simulation hat Daten entfernt: %v", readError)
}
}
func TestPruneRefusesWhenManifestIsUnreadable(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
// Ein unlesbares Manifest bedeutet: die Menge der benötigten Chunks ist unbekannt.
manifestFilePath := testRepository.manifestPath("backup-1")
_ = os.Chmod(manifestFilePath, 0o600)
_ = os.WriteFile(manifestFilePath, []byte("kaputt"), 0o600)
// Eine Bereinigung auf unvollständiger Grundlage könnte Daten vernichten.
if _, _, pruneError := testRepository.PruneOrphanedChunks(context.Background(), false); pruneError == nil {
testInstance.Fatal("die Bereinigung muss bei einem unlesbaren Manifest abbrechen")
}
}
// ---------------------------------------------------------------------------
// Sperre und Neustart
// ---------------------------------------------------------------------------
func TestSecondWriterIsRejectedWhileRepositoryIsLocked(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
// Ein zweiter Schreiber würde Katalog und Manifeste durcheinanderbringen.
_, openError := Open(context.Background(), testRepository.RootPath(), OpenOptions{}, newTestLogger())
if !errors.Is(openError, ErrRepositoryLocked) {
testInstance.Fatalf("ein zweiter Schreiber muss abgelehnt werden, war: %v", openError)
}
}
func TestReadOnlyAccessIsPossibleWhileLocked(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
// Eine Wiederherstellung muss auch während eines laufenden Backups möglich sein.
readerRepository, openError := Open(context.Background(), testRepository.RootPath(),
OpenOptions{ReadOnly: true}, newTestLogger())
if openError != nil {
testInstance.Fatalf("der lesende Zugriff wurde abgelehnt: %v", openError)
}
defer func() { _ = readerRepository.Close() }()
if _, readError := readerRepository.ReadManifest(context.Background(), "backup-1"); readError != nil {
testInstance.Fatalf("das Manifest konnte lesend nicht geöffnet werden: %v", readError)
}
}
func TestReadOnlyRepositoryRefusesToWrite(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
repositoryPath := testRepository.RootPath()
_ = testRepository.Close()
readerRepository, _ := Open(context.Background(), repositoryPath, OpenOptions{ReadOnly: true}, newTestLogger())
defer func() { _ = readerRepository.Close() }()
// Ohne Sperre darf nicht geschrieben werden.
if _, beginError := readerRepository.BeginBackup(context.Background(), "neu"); beginError == nil {
testInstance.Fatal("ein nur lesend geöffnetes Repository darf nicht beschrieben werden")
}
}
func TestRepositorySurvivesReopen(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
repositoryPath := testRepository.RootPath()
writeTestBackup(testInstance, testRepository, "vor-neustart", [][]byte{[]byte("Bestandsdaten")})
// Ein Neustart des Dienstes: schließen und erneut öffnen.
if closeError := testRepository.Close(); closeError != nil {
testInstance.Fatalf("das Repository konnte nicht geschlossen werden: %v", closeError)
}
reopenedRepository, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger())
if openError != nil {
testInstance.Fatalf("das Repository konnte nach dem Neustart nicht geöffnet werden: %v", openError)
}
defer func() { _ = reopenedRepository.Close() }()
if _, readError := reopenedRepository.ReadManifest(context.Background(), "vor-neustart"); readError != nil {
testInstance.Fatalf("das Backup überstand den Neustart nicht: %v", readError)
}
// Nach dem Neustart muss weitergeschrieben werden können.
writeTestBackup(testInstance, reopenedRepository, "nach-neustart", [][]byte{[]byte("Neue Daten")})
catalogEntries, _ := reopenedRepository.ListBackups(context.Background())
if len(catalogEntries) != 2 {
testInstance.Fatalf("es sollen 2 Backups vorliegen, waren %d", len(catalogEntries))
}
}
func TestBreakLockAllowsRecoveryAfterCrash(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
repositoryPath := testRepository.RootPath()
// Ein Absturz hinterlässt die Sperrdatei, ohne dass ein Prozess läuft.
// Der Prozess wird hier nicht sauber geschlossen, die Sperre bleibt liegen.
testRepository.lockHandle = nil
if _, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger()); !errors.Is(openError, ErrRepositoryLocked) {
testInstance.Fatalf("die zurückgebliebene Sperre soll den Zugriff verhindern, war: %v", openError)
}
// Erst der ausdrückliche Eingriff gibt das Repository frei.
if breakError := BreakLock(repositoryPath); breakError != nil {
testInstance.Fatalf("die Sperre konnte nicht gelöst werden: %v", breakError)
}
recoveredRepository, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger())
if openError != nil {
testInstance.Fatalf("das Repository konnte nach dem Lösen der Sperre nicht geöffnet werden: %v", openError)
}
_ = recoveredRepository.Close()
}
// ---------------------------------------------------------------------------
// Unterbrochener Schreibvorgang
// ---------------------------------------------------------------------------
func TestInterruptedSessionLeavesNoVisibleBackup(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
repositoryPath := testRepository.RootPath()
// Eine Session wird geöffnet und Daten geschrieben, aber nie abgeschlossen.
backupWriter, _ := testRepository.BeginBackup(context.Background(), "abgestuerzt")
_, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten vor dem Absturz"))
// Der Absturz: kein Commit, kein Abort, das Journal bleibt liegen.
testRepository.lockHandle = nil
_ = BreakLock(repositoryPath)
recoveredRepository, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger())
if openError != nil {
testInstance.Fatalf("das Repository konnte nicht geöffnet werden: %v", openError)
}
defer func() { _ = recoveredRepository.Close() }()
// Ein unvollständiges Backup darf nicht sichtbar sein.
if _, readError := recoveredRepository.ReadManifest(context.Background(), "abgestuerzt"); !errors.Is(readError, ErrBackupNotFound) {
testInstance.Fatalf("ein abgestürzter Lauf darf kein Backup hinterlassen, war: %v", readError)
}
// Der Abbruch muss aber auffindbar sein und darf nicht still verschwinden.
staleSessions, staleError := recoveredRepository.FindStaleSessions(context.Background())
if staleError != nil {
testInstance.Fatalf("die offenen Sessions konnten nicht ermittelt werden: %v", staleError)
}
if len(staleSessions) != 1 {
testInstance.Fatalf("es soll 1 zurückgebliebene Session gefunden werden, waren %d", len(staleSessions))
}
if staleSessions[0].BackupID != "abgestuerzt" || staleSessions[0].HasManifest {
testInstance.Errorf("die zurückgebliebene Session wurde falsch beschrieben: %+v", staleSessions[0])
}
// Nach dem Aufräumen ist sie verschwunden.
if cleanupError := recoveredRepository.CleanupStaleSession(context.Background(), staleSessions[0].SessionID); cleanupError != nil {
testInstance.Fatalf("die Session konnte nicht aufgeräumt werden: %v", cleanupError)
}
remainingSessions, _ := recoveredRepository.FindStaleSessions(context.Background())
if len(remainingSessions) != 0 {
testInstance.Errorf("nach dem Aufräumen soll keine Session zurückbleiben, waren %d", len(remainingSessions))
}
}
func TestChunksOfInterruptedSessionAreReused(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
sharedContent := []byte("Dieser Block wurde vor dem Absturz geschrieben.")
// Ein abgebrochener Lauf hinterlässt seine Chunks.
firstWriter, _ := testRepository.BeginBackup(context.Background(), "erster-versuch")
_, _, _ = firstWriter.WriteChunk(context.Background(), sharedContent)
_ = firstWriter.Abort(context.Background())
// Der zweite Anlauf findet sie vor und muss sie nicht erneut übertragen.
secondWriter, _ := testRepository.BeginBackup(context.Background(), "zweiter-versuch")
_, wasNew, _ := secondWriter.WriteChunk(context.Background(), sharedContent)
if wasNew {
testInstance.Error("bereits übertragene Blöcke sollen nach einem Abbruch wiederverwendet werden")
}
_ = secondWriter.Abort(context.Background())
}
// ---------------------------------------------------------------------------
// Aufbewahrungsschutz
// ---------------------------------------------------------------------------
func TestHardenedRepositoryProtectsBackupFromDeletion(testInstance *testing.T) {
hardenedRepository, createError := Create(context.Background(), testInstance.TempDir(), CreateOptions{
Name: "Gehärtetes Repository",
Kind: KindHardenedLinux,
CreatedByVersion: "test",
}, newTestLogger())
if createError != nil {
testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError)
}
defer func() { _ = hardenedRepository.Close() }()
// Der Löschschutz greift bis in das Aufräumen des Tests hinein: Ohne diese
// Freigabe scheitert t.TempDir am geschützten Manifest.
testInstance.Cleanup(func() { releaseAllImmutableFlagsForTest(testInstance, hardenedRepository.RootPath()) })
// Ein gehärtetes Repository ist immer unveränderlich.
if !hardenedRepository.Descriptor().Immutable {
testInstance.Fatal("ein gehärtetes Repository muss unveränderlich sein")
}
writeTestBackup(testInstance, hardenedRepository, "geschuetzt", [][]byte{[]byte("Wichtige Daten")})
manifest, _ := hardenedRepository.ReadManifest(context.Background(), "geschuetzt")
if manifest.ImmutableUntil == nil {
testInstance.Fatal("dem Backup fehlt die Aufbewahrungsfrist")
}
// Das Löschen muss scheitern, solange die Frist läuft.
deleteError := hardenedRepository.DeleteBackup(context.Background(), "geschuetzt")
if !errors.Is(deleteError, ErrRetentionLocked) {
testInstance.Fatalf("ein geschütztes Backup darf nicht gelöscht werden, war: %v", deleteError)
}
// Das Backup ist danach unverändert vorhanden.
if _, readError := hardenedRepository.ReadManifest(context.Background(), "geschuetzt"); readError != nil {
testInstance.Fatalf("das geschützte Backup wurde beschädigt: %v", readError)
}
}
func TestDeleteRemovesBackupAfterRetentionExpired(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "abgelaufen", [][]byte{[]byte("Alte Daten")})
// Ein gewöhnliches Repository setzt keine Frist; das Löschen ist erlaubt.
if deleteError := testRepository.DeleteBackup(context.Background(), "abgelaufen"); deleteError != nil {
testInstance.Fatalf("das Backup konnte nicht gelöscht werden: %v", deleteError)
}
if _, readError := testRepository.ReadManifest(context.Background(), "abgelaufen"); !errors.Is(readError, ErrBackupNotFound) {
testInstance.Fatalf("das Backup ist noch vorhanden, war: %v", readError)
}
// Der Katalog folgt der Löschung.
catalogEntries, _ := testRepository.ListBackups(context.Background())
if len(catalogEntries) != 0 {
testInstance.Errorf("der Katalog soll leer sein, enthielt %d Einträge", len(catalogEntries))
}
}
func TestRetentionLockExpires(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
backupWriter, _ := testRepository.BeginBackup(context.Background(), "kurze-frist")
_, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten"))
// Eine bereits abgelaufene Frist.
expiredRetention := time.Now().Add(-time.Hour)
_ = backupWriter.Commit(context.Background(), &Manifest{
Source: SourceInformation{SourceType: "filesystem"},
ImmutableUntil: &expiredRetention,
})
// Nach Ablauf der Frist darf gelöscht werden.
if deleteError := testRepository.DeleteBackup(context.Background(), "kurze-frist"); deleteError != nil {
testInstance.Fatalf("nach Ablauf der Frist muss das Löschen möglich sein: %v", deleteError)
}
}
// ---------------------------------------------------------------------------
// Zustand
// ---------------------------------------------------------------------------
func TestHealthReportsUsableRepository(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Daten")})
healthReport, healthError := testRepository.Health(context.Background())
if healthError != nil {
testInstance.Fatalf("der Zustand konnte nicht ermittelt werden: %v", healthError)
}
// Der Status haengt vom tatsaechlichen Fuellstand des Testrechners ab.
// Entscheidend ist hier, dass das Repository erreichbar und beschreibbar
// ist; die Schwellenbewertung wird gesondert geprueft.
if healthReport.Status == HealthStatusCritical && healthReport.CapacityBytes == 0 {
testInstance.Errorf("das Repository soll erreichbar sein, war %q: %s",
healthReport.Status, healthReport.Message)
}
if healthReport.BackupCount != 1 {
testInstance.Errorf("es soll 1 Backup gezählt werden, waren %d", healthReport.BackupCount)
}
// Ohne Kapazitätsangabe wäre die Kapazitätswarnung wirkungslos.
if healthReport.CapacityBytes <= 0 {
testInstance.Error("die Kapazität konnte nicht ermittelt werden")
}
}
func TestHealthDetectsUnreachableRepository(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
// Der Descriptor verschwindet, etwa weil der Datenträger nicht mehr eingebunden ist.
if removeError := os.Remove(testRepository.descriptorPath()); removeError != nil {
testInstance.Fatalf("der Descriptor konnte nicht entfernt werden: %v", removeError)
}
healthReport, _ := testRepository.Health(context.Background())
if healthReport.Status != HealthStatusCritical {
testInstance.Fatalf("ein unerreichbares Repository muss als kritisch gelten, war %q", healthReport.Status)
}
// Der Anwender braucht eine Handlungsempfehlung, keinen Fehlercode.
if healthReport.RecommendedAction == "" {
testInstance.Error("es fehlt eine Handlungsempfehlung")
}
}
// ---------------------------------------------------------------------------
// Backup-Ketten
// ---------------------------------------------------------------------------
func TestValidateChainDetectsBrokenChain(testInstance *testing.T) {
testRepository := newTestRepository(testInstance)
// Eine Kette aus Vollsicherung und zwei Zusatzsicherungen.
writeChainBackup(testInstance, testRepository, "voll", "kette-1", "", BackupTypeFull)
writeChainBackup(testInstance, testRepository, "inkrement-1", "kette-1", "voll", BackupTypeIncremental)
writeChainBackup(testInstance, testRepository, "inkrement-2", "kette-1", "inkrement-1", BackupTypeIncremental)
catalog, _ := testRepository.Catalog(context.Background())
validationResult := catalog.ValidateChain("kette-1")
if !validationResult.IsValid {
testInstance.Fatalf("die vollständige Kette soll gültig sein: %s", validationResult.Message)
}
// Das mittlere Glied fällt aus.
if deleteError := testRepository.DeleteBackup(context.Background(), "inkrement-1"); deleteError != nil {
testInstance.Fatalf("das Backup konnte nicht gelöscht werden: %v", deleteError)
}
brokenCatalog, _ := testRepository.Catalog(context.Background())
brokenResult := brokenCatalog.ValidateChain("kette-1")
// Eine unterbrochene Kette darf niemals als in Ordnung erscheinen.
if brokenResult.IsValid {
testInstance.Fatal("die unterbrochene Kette wurde als gültig gemeldet")
}
if len(brokenResult.AffectedBackupIDs) == 0 {
testInstance.Error("die betroffenen Wiederherstellungspunkte wurden nicht benannt")
}
}
// writeChainBackup schreibt ein Backup als Teil einer Kette.
func writeChainBackup(testInstance *testing.T, testRepository *LocalRepository, backupID string, chainID string, parentID string, backupType BackupType) {
testInstance.Helper()
backupWriter, beginError := testRepository.BeginBackup(context.Background(), backupID)
if beginError != nil {
testInstance.Fatalf("die Schreibsession konnte nicht geöffnet werden: %v", beginError)
}
chunkReference, _, _ := backupWriter.WriteChunk(context.Background(), []byte("Daten von "+backupID))
commitError := backupWriter.Commit(context.Background(), &Manifest{
ChainID: chainID,
ParentBackupID: parentID,
BackupType: backupType,
Source: SourceInformation{SourceType: "filesystem", SourceID: "quelle-1"},
Entries: []ManifestEntry{{
Path: "/daten.bin",
EntryType: "file",
Chunks: []ChunkReference{chunkReference},
}},
})
if commitError != nil {
testInstance.Fatalf("das Backup konnte nicht abgeschlossen werden: %v", commitError)
}
}
func TestEvaluateCapacityThresholds(testInstance *testing.T) {
capacityCases := []struct {
usedPercentage float64
expectedStatus HealthStatus
}{
{0, HealthStatusHealthy},
{50, HealthStatusHealthy},
{79.9, HealthStatusHealthy},
{80, HealthStatusWarning},
{85, HealthStatusWarning},
{89.9, HealthStatusWarning},
{90, HealthStatusCritical},
{99.9, HealthStatusCritical},
}
for _, capacityCase := range capacityCases {
actualStatus, statusMessage, recommendedAction := evaluateCapacity(capacityCase.usedPercentage)
if actualStatus != capacityCase.expectedStatus {
testInstance.Errorf("bei %.1f %% Belegung soll der Status %q sein, war %q",
capacityCase.usedPercentage, capacityCase.expectedStatus, actualStatus)
}
// Ein Problem ohne Handlungsempfehlung laesst den Anwender ratlos zurueck.
if actualStatus != HealthStatusHealthy && recommendedAction == "" {
testInstance.Errorf("bei %.1f %% Belegung fehlt die Handlungsempfehlung", capacityCase.usedPercentage)
}
if statusMessage == "" {
testInstance.Errorf("bei %.1f %% Belegung fehlt die Meldung", capacityCase.usedPercentage)
}
}
}