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) } } }