syncova-backup/packages/agent/discovery_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

457 lines
16 KiB
Go

package agent
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
)
// buildTestTree legt einen Verzeichnisbaum für Tests an.
func buildTestTree(testInstance *testing.T, fileContents map[string]string) string {
testInstance.Helper()
rootPath := testInstance.TempDir()
for relativePath, fileContent := range fileContents {
fullPath := filepath.Join(rootPath, relativePath)
if directoryError := os.MkdirAll(filepath.Dir(fullPath), 0o755); directoryError != nil {
testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError)
}
if writeError := os.WriteFile(fullPath, []byte(fileContent), 0o644); writeError != nil {
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError)
}
}
return rootPath
}
// findEntry sucht einen Eintrag anhand seines relativen Pfads.
func findEntry(discoveryResult *DiscoveryResult, relativePath string) (DiscoveredEntry, bool) {
for _, discoveredEntry := range discoveryResult.Entries {
if discoveredEntry.RelativePath == relativePath {
return discoveredEntry, true
}
}
return DiscoveredEntry{}, false
}
// ---------------------------------------------------------------------------
// Erfassung
// ---------------------------------------------------------------------------
func TestDiscoverFindsAllFiles(testInstance *testing.T) {
rootPath := buildTestTree(testInstance, map[string]string{
"datei-a.txt": "Inhalt A",
"unterordner/datei-b.txt": "Inhalt B",
"unterordner/tief/c.txt": "Inhalt C",
})
discoveryResult, discoveryError := Discover(rootPath, DiscoveryOptions{})
if discoveryError != nil {
testInstance.Fatalf("die Erfassung schlug fehl: %v", discoveryError)
}
if discoveryResult.FileCount() != 3 {
testInstance.Fatalf("es sollen 3 Dateien gefunden werden, waren %d", discoveryResult.FileCount())
}
// Die Pfade werden mit Schrägstrich abgelegt, damit ein unter Windows
// erstelltes Backup auch unter Linux nutzbar bleibt.
if _, wasFound := findEntry(discoveryResult, "unterordner/tief/c.txt"); !wasFound {
testInstance.Errorf("der erwartete Pfad fehlt: %+v", discoveryResult.Entries)
}
expectedBytes := int64(len("Inhalt A") + len("Inhalt B") + len("Inhalt C"))
if discoveryResult.TotalBytes != expectedBytes {
testInstance.Errorf("die Gesamtgröße soll %d sein, war %d", expectedBytes, discoveryResult.TotalBytes)
}
}
func TestDiscoverSortsEntriesForReproducibility(testInstance *testing.T) {
rootPath := buildTestTree(testInstance, map[string]string{
"zebra.txt": "z", "alpha.txt": "a", "mitte.txt": "m",
})
// Zwei Läufe über denselben Bestand müssen dieselbe Reihenfolge ergeben,
// sonst wären die entstehenden Backups nicht vergleichbar.
firstResult, _ := Discover(rootPath, DiscoveryOptions{})
secondResult, _ := Discover(rootPath, DiscoveryOptions{})
if fmt.Sprint(firstResult.Entries) != fmt.Sprint(secondResult.Entries) {
testInstance.Fatal("zwei Läufe liefern unterschiedliche Reihenfolgen")
}
if firstResult.Entries[0].RelativePath != "alpha.txt" {
testInstance.Errorf("die Einträge sind nicht sortiert: %s", firstResult.Entries[0].RelativePath)
}
}
func TestDiscoverIncludesDirectoriesOnRequest(testInstance *testing.T) {
rootPath := buildTestTree(testInstance, map[string]string{
"ordner/datei.txt": "Inhalt",
})
withoutDirectories, _ := Discover(rootPath, DiscoveryOptions{})
for _, discoveredEntry := range withoutDirectories.Entries {
if discoveredEntry.EntryType == EntryTypeDirectory {
testInstance.Error("ohne Anforderung sollen keine Verzeichnisse erfasst werden")
}
}
// Verzeichnisse tragen keine Daten, aber ihre Rechte - nötig für eine
// vollständige Wiederherstellung.
withDirectories, _ := Discover(rootPath, DiscoveryOptions{IncludeDirectories: true})
directoryEntry, wasFound := findEntry(withDirectories, "ordner")
if !wasFound {
testInstance.Fatal("das Verzeichnis wurde nicht erfasst")
}
if directoryEntry.Mode == "" {
testInstance.Error("dem Verzeichnis fehlen die Rechte")
}
}
func TestDiscoverHandlesSingleFileAsRoot(testInstance *testing.T) {
rootPath := buildTestTree(testInstance, map[string]string{"einzeln.txt": "Inhalt"})
filePath := filepath.Join(rootPath, "einzeln.txt")
discoveryResult, discoveryError := Discover(filePath, DiscoveryOptions{})
if discoveryError != nil {
testInstance.Fatalf("die Erfassung einer einzelnen Datei schlug fehl: %v", discoveryError)
}
if discoveryResult.FileCount() != 1 {
testInstance.Fatalf("es soll 1 Datei erfasst werden, waren %d", discoveryResult.FileCount())
}
}
func TestDiscoverReportsMissingRoot(testInstance *testing.T) {
_, discoveryError := Discover(filepath.Join(testInstance.TempDir(), "gibt-es-nicht"), DiscoveryOptions{})
if !errors.Is(discoveryError, ErrRootNotFound) {
testInstance.Fatalf("ein fehlendes Verzeichnis muss gemeldet werden, war: %v", discoveryError)
}
}
// ---------------------------------------------------------------------------
// Ein- und Ausschlussregeln
// ---------------------------------------------------------------------------
func TestExcludePatternsSkipMatchingFiles(testInstance *testing.T) {
rootPath := buildTestTree(testInstance, map[string]string{
"wichtig.txt": "behalten",
"zwischen.tmp": "verwerfen",
"logs/heute.log": "verwerfen",
"logs/gestern.log": "verwerfen",
})
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{
ExcludePatterns: []string{"*.tmp", "logs"},
})
if discoveryResult.FileCount() != 1 {
testInstance.Fatalf("es soll 1 Datei übrig bleiben, waren %d: %+v",
discoveryResult.FileCount(), discoveryResult.Entries)
}
// Ein Muster ohne Platzhalter wirkt auf den gesamten Teilbaum.
if _, wasFound := findEntry(discoveryResult, "logs/heute.log"); wasFound {
testInstance.Error("das ausgeschlossene Verzeichnis wurde dennoch erfasst")
}
if discoveryResult.SkippedByPattern == 0 {
testInstance.Error("die Zahl der übergangenen Objekte wurde nicht vermerkt")
}
}
func TestIncludePatternsRestrictSelection(testInstance *testing.T) {
rootPath := buildTestTree(testInstance, map[string]string{
"bericht.pdf": "pdf",
"tabelle.xlsx": "xlsx",
"unterordner/x.pdf": "pdf",
"notiz.txt": "txt",
})
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{
IncludePatterns: []string{"*.pdf"},
})
if discoveryResult.FileCount() != 2 {
testInstance.Fatalf("es sollen 2 PDF-Dateien erfasst werden, waren %d: %+v",
discoveryResult.FileCount(), discoveryResult.Entries)
}
// Ein Verzeichnis darf nicht wegen der Einschlussregeln übergangen werden -
// darunter liegen passende Dateien.
if _, wasFound := findEntry(discoveryResult, "unterordner/x.pdf"); !wasFound {
testInstance.Error("die passende Datei im Unterordner wurde nicht erfasst")
}
}
func TestExcludeTakesPrecedenceOverInclude(testInstance *testing.T) {
rootPath := buildTestTree(testInstance, map[string]string{
"bericht.pdf": "behalten",
"entwurf.pdf": "verwerfen",
})
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{
IncludePatterns: []string{"*.pdf"},
ExcludePatterns: []string{"entwurf.pdf"},
})
// Wer etwas ausdrücklich ausnimmt, meint es.
if discoveryResult.FileCount() != 1 {
testInstance.Fatalf("der Ausschluss soll vorgehen, es blieben %d Dateien", discoveryResult.FileCount())
}
}
// ---------------------------------------------------------------------------
// Rechtefehler
// ---------------------------------------------------------------------------
func TestPermissionErrorsAreCollectedNotFatal(testInstance *testing.T) {
if runtime.GOOS == "windows" {
testInstance.Skip("Dateirechte verhalten sich unter Windows anders")
}
if os.Geteuid() == 0 {
// Als Administrator greifen die Rechte nicht; der Test wäre wertlos.
testInstance.Skip("der Test setzt ein unprivilegiertes Konto voraus")
}
rootPath := buildTestTree(testInstance, map[string]string{
"lesbar.txt": "kann gelesen werden",
"gesperrt/geheim.txt": "kein Zugriff",
})
gesperrtesVerzeichnis := filepath.Join(rootPath, "gesperrt")
if chmodError := os.Chmod(gesperrtesVerzeichnis, 0o000); chmodError != nil {
testInstance.Fatalf("die Rechte konnten nicht gesetzt werden: %v", chmodError)
}
// Die Rechte werden zurückgesetzt, damit das Testverzeichnis aufräumbar bleibt.
testInstance.Cleanup(func() { _ = os.Chmod(gesperrtesVerzeichnis, 0o755) })
discoveryResult, discoveryError := Discover(rootPath, DiscoveryOptions{})
// Eine gesperrte Datei darf nicht den ganzen Lauf verhindern.
if discoveryError != nil {
testInstance.Fatalf("ein Rechtefehler darf die Erfassung nicht abbrechen: %v", discoveryError)
}
if discoveryResult.FileCount() < 1 {
testInstance.Error("die lesbare Datei wurde nicht erfasst")
}
// Verschwiegen werden darf das Problem aber niemals.
if !discoveryResult.HasProblems() {
testInstance.Fatal("der Rechtefehler wurde nicht vermerkt")
}
if discoveryResult.PermissionProblemCount() == 0 {
testInstance.Error("der Fehler wurde nicht als Rechtefehler erkannt")
}
// Die Meldung soll verständlich sein, keine Systemmeldung.
firstProblem := discoveryResult.Problems[0]
if firstProblem.Reason == "" {
testInstance.Error("dem Problem fehlt eine Erklärung")
}
}
// ---------------------------------------------------------------------------
// Sonderfälle
// ---------------------------------------------------------------------------
func TestSymlinksAreRecordedNotFollowed(testInstance *testing.T) {
if runtime.GOOS == "windows" {
testInstance.Skip("symbolische Verweise verlangen unter Windows besondere Rechte")
}
rootPath := buildTestTree(testInstance, map[string]string{"ziel.txt": "Inhalt"})
verweisPfad := filepath.Join(rootPath, "verweis.txt")
if linkError := os.Symlink(filepath.Join(rootPath, "ziel.txt"), verweisPfad); linkError != nil {
testInstance.Fatalf("der Verweis konnte nicht angelegt werden: %v", linkError)
}
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{})
verweisEintrag, wasFound := findEntry(discoveryResult, "verweis.txt")
if !wasFound {
testInstance.Fatal("der symbolische Verweis wurde nicht erfasst")
}
if verweisEintrag.EntryType != EntryTypeSymlink {
testInstance.Errorf("der Verweis soll als solcher erfasst sein, war %q", verweisEintrag.EntryType)
}
// Das Ziel muss festgehalten werden, sonst liesse sich der Verweis nicht
// wiederherstellen.
if verweisEintrag.LinkTarget == "" {
testInstance.Error("dem Verweis fehlt sein Ziel")
}
}
func TestSymlinkLoopDoesNotHang(testInstance *testing.T) {
if runtime.GOOS == "windows" {
testInstance.Skip("symbolische Verweise verlangen unter Windows besondere Rechte")
}
rootPath := testInstance.TempDir()
unterordner := filepath.Join(rootPath, "unterordner")
if directoryError := os.MkdirAll(unterordner, 0o755); directoryError != nil {
testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError)
}
// Ein Verweis auf das übergeordnete Verzeichnis erzeugte bei Verfolgung
// eine endlose Schleife.
if linkError := os.Symlink(rootPath, filepath.Join(unterordner, "zurueck")); linkError != nil {
testInstance.Fatalf("der Verweis konnte nicht angelegt werden: %v", linkError)
}
// Der Lauf muss beenden; ohne Zeitbegrenzung liefe der Test sonst ewig.
discoveryDone := make(chan struct{})
go func() {
defer close(discoveryDone)
_, _ = Discover(rootPath, DiscoveryOptions{})
}()
select {
case <-discoveryDone:
case <-timeoutAfterSeconds(10):
testInstance.Fatal("die Erfassung ist in einer Verweisschleife hängen geblieben")
}
}
func TestLargeFileIsRecordedWithCorrectSize(testInstance *testing.T) {
rootPath := testInstance.TempDir()
// Eine Datei jenseits gängiger Puffergrößen.
const largeFileSize = 40 * 1024 * 1024
largeFilePath := filepath.Join(rootPath, "gross.bin")
largeFile, createError := os.Create(largeFilePath)
if createError != nil {
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", createError)
}
if truncateError := largeFile.Truncate(largeFileSize); truncateError != nil {
testInstance.Fatalf("die Datei konnte nicht vergrößert werden: %v", truncateError)
}
_ = largeFile.Close()
discoveryResult, discoveryError := Discover(rootPath, DiscoveryOptions{})
if discoveryError != nil {
testInstance.Fatalf("die Erfassung schlug fehl: %v", discoveryError)
}
largeEntry, wasFound := findEntry(discoveryResult, "gross.bin")
if !wasFound {
testInstance.Fatal("die große Datei wurde nicht erfasst")
}
if largeEntry.SizeBytes != largeFileSize {
testInstance.Errorf("die Größe soll %d sein, war %d", largeFileSize, largeEntry.SizeBytes)
}
}
func TestFileExceedingMaximumSizeIsReportedNotSilentlySkipped(testInstance *testing.T) {
rootPath := buildTestTree(testInstance, map[string]string{
"klein.txt": "ok",
"gross.txt": "diese Zeile ist deutlich länger als die Grenze",
})
discoveryResult, _ := Discover(rootPath, DiscoveryOptions{MaximumFileSize: 10})
if discoveryResult.FileCount() != 1 {
testInstance.Errorf("es soll 1 Datei übrig bleiben, waren %d", discoveryResult.FileCount())
}
// Eine übergangene Datei muss vermerkt werden, sonst hielte der Anwender
// das Backup für vollständig.
if !discoveryResult.HasProblems() {
testInstance.Fatal("die übergroße Datei wurde stillschweigend übergangen")
}
}
func TestManySmallFiles(testInstance *testing.T) {
rootPath := testInstance.TempDir()
// Viele kleine Dateien sind ein eigener Belastungsfall: der Aufwand liegt
// im Verzeichniszugriff, nicht in der Datenmenge.
const fileCount = 5000
for fileIndex := 0; fileIndex < fileCount; fileIndex++ {
// Eine Aufteilung auf Unterverzeichnisse entspricht der Praxis.
subDirectory := filepath.Join(rootPath, fmt.Sprintf("ordner-%02d", fileIndex%50))
if directoryError := os.MkdirAll(subDirectory, 0o755); directoryError != nil {
testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError)
}
filePath := filepath.Join(subDirectory, fmt.Sprintf("datei-%05d.txt", fileIndex))
if writeError := os.WriteFile(filePath, []byte("x"), 0o644); writeError != nil {
testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError)
}
}
discoveryResult, discoveryError := Discover(rootPath, DiscoveryOptions{})
if discoveryError != nil {
testInstance.Fatalf("die Erfassung schlug fehl: %v", discoveryError)
}
if discoveryResult.FileCount() != fileCount {
testInstance.Fatalf("es sollen %d Dateien erfasst werden, waren %d", fileCount, discoveryResult.FileCount())
}
if discoveryResult.HasProblems() {
testInstance.Errorf("es traten unerwartete Probleme auf: %+v", discoveryResult.Problems[0])
}
}
func TestEmptyDirectoryYieldsNoEntries(testInstance *testing.T) {
discoveryResult, discoveryError := Discover(testInstance.TempDir(), DiscoveryOptions{})
if discoveryError != nil {
testInstance.Fatalf("die Erfassung schlug fehl: %v", discoveryError)
}
// Ein leeres Verzeichnis ist kein Fehler, liefert aber auch nichts.
if len(discoveryResult.Entries) != 0 {
testInstance.Errorf("es sollen keine Einträge entstehen, waren %d", len(discoveryResult.Entries))
}
}
func TestMatchesPatternBehaviour(testInstance *testing.T) {
patternCases := []struct {
pathValue string
patternValue string
shouldMatch bool
caseDescription string
}{
{"datei.tmp", "*.tmp", true, "Endung"},
{"ordner/datei.tmp", "*.tmp", true, "Endung im Unterordner"},
{"logs/app.log", "logs", true, "Verzeichnis als Präfix"},
{"logs", "logs", true, "Verzeichnis selbst"},
{"logsammlung/x.txt", "logs", false, "ähnlicher Name ohne Trennzeichen"},
{"datei.txt", "*.tmp", false, "andere Endung"},
}
for _, patternCase := range patternCases {
actualMatch := matchesPattern(patternCase.pathValue, patternCase.patternValue)
if actualMatch != patternCase.shouldMatch {
testInstance.Errorf("%s: %q gegen %q soll %v ergeben, war %v",
patternCase.caseDescription, patternCase.pathValue, patternCase.patternValue,
patternCase.shouldMatch, actualMatch)
}
}
}