package disasterrecovery import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/google/uuid" ) // buildSampleSnapshot erzeugt einen Sicherungssatz mit allen Bestandteilen. func buildSampleSnapshot() *Snapshot { return &Snapshot{ FormatVersion: SnapshotFormatVersion, RepositoryID: "11111111-2222-3333-4444-555555555555", CreatedAt: time.Date(2026, 8, 13, 6, 0, 0, 0, time.UTC), CreatedBy: "syncova-dr", ProductVersion: "0.1.0-dev", SchemaVersion: 10, Repositories: []RepositoryRecord{ {ID: "aaaaaaaa-0000-0000-0000-000000000001", Name: "haupt", RepositoryType: "local", Location: "/srv/backups", Status: "active"}, }, Jobs: []JobRecord{ {ID: "bbbbbbbb-0000-0000-0000-000000000001", Name: "Dateiserver", Status: "active", Priority: "high", ScheduleType: "daily", RepositoryID: "aaaaaaaa-0000-0000-0000-000000000001"}, }, NotificationChannels: []NotificationChannelRecord{ {ID: "cccccccc-0000-0000-0000-000000000001", Name: "Bereitschaft", ChannelType: "email", MinimumSeverity: "high", WasEnabled: true}, }, Users: []UserRecord{ {ID: "dddddddd-0000-0000-0000-000000000001", Username: "admin", Email: "admin@example.org", Status: "active", Roles: []string{"super_administrator"}}, }, OmittedForSecurity: securityOmissions(), } } // TestSnapshotCarriesNoSecrets ist der wichtigste Test des Pakets. // // Ein Sicherungssatz liegt im Repository, und ein Repository liegt naturgemaess // dort, wo es einen Serverausfall ueberlebt: ausserhalb der Anlage, womoeglich // bei einem Dienstleister. Ein Passwort-Hash oder ein SMTP-Zugang, der dort // einmal gelandet ist, laesst sich nicht zurueckholen — die Datei ist bereits // geschrieben. // // Geprueft wird die **serialisierte** Form, nicht die Struktur: Ein Feld, das // jemand spaeter ergaenzt, faellt nur so auf. func TestSnapshotCarriesNoSecrets(testInstance *testing.T) { // Die Liste der Auslassungen wird vorher entfernt: In ihr **stehen** die // Begriffe mit Absicht („Zweite Faktoren (TOTP-Geheimnisse)"). Sie // mitzuprüfen ergäbe einen Alarm auf genau den Text, der vor dem Problem // warnt — und ein Prüfwerkzeug, das grundlos anschlägt, wird bald nicht mehr // ernst genommen. snapshotUnderTest := buildSampleSnapshot() snapshotUnderTest.OmittedForSecurity = nil encodedSnapshot, encodeError := json.Marshal(snapshotUnderTest) if encodeError != nil { testInstance.Fatalf("der Sicherungssatz ließ sich nicht kodieren: %v", encodeError) } serializedText := strings.ToLower(string(encodedSnapshot)) // Feldnamen, die es in einem Sicherungssatz niemals geben darf. forbiddenFieldNames := []string{ "password_hash", "passwordhash", "secret_ciphertext", "credentials_ciphertext", "secret_key", "private_key", "access_token", "refresh_token", "\"configuration\"", "totp", } for _, forbiddenField := range forbiddenFieldNames { if strings.Contains(serializedText, forbiddenField) { testInstance.Errorf("der Sicherungssatz enthält das Feld %q", forbiddenField) } } } // TestUserRecordHasNoPasswordField prueft die Struktur des Kontos. // // Ergaenzend zum Test oben: Dieser faellt auch dann auf, wenn jemand ein Feld // hinzufuegt, das im Beispiel leer bleibt und deshalb aus dem JSON verschwindet. func TestUserRecordHasNoPasswordField(testInstance *testing.T) { encodedUser, encodeError := json.Marshal(UserRecord{Username: "test"}) if encodeError != nil { testInstance.Fatalf("das Konto ließ sich nicht kodieren: %v", encodeError) } var decodedFields map[string]any if decodeError := json.Unmarshal(encodedUser, &decodedFields); decodeError != nil { testInstance.Fatalf("das Konto ließ sich nicht lesen: %v", decodeError) } allowedFields := map[string]bool{ "id": true, "username": true, "email": true, "status": true, "roles": true, } for fieldName := range decodedFields { if !allowedFields[fieldName] { testInstance.Errorf("das Konto trägt das unerwartete Feld %q — jedes neue Feld "+ "hier ist ein Kandidat für ein Geheimnis im Repository", fieldName) } } } // TestOmissionsAreCarriedInTheSnapshot prueft die Handlungsanweisung. // // Wer nach einem Totalverlust eine Anlage wiederherstellt, hat die // Betriebsanleitung nicht dabei. Was fehlt, muss aus der Datei selbst // hervorgehen. func TestOmissionsAreCarriedInTheSnapshot(testInstance *testing.T) { sampleSnapshot := buildSampleSnapshot() if len(sampleSnapshot.OmittedForSecurity) == 0 { testInstance.Fatal("der Sicherungssatz benennt nicht, was ihm fehlt") } joinedOmissions := strings.ToLower(strings.Join(sampleSnapshot.OmittedForSecurity, " ")) // Jede weggelassene Art von Geheimnis muss genannt sein. for _, requiredMention := range []string{"passwör", "faktor", "zugangsdaten", "token", "schlüssel"} { if !strings.Contains(joinedOmissions, requiredMention) { testInstance.Errorf("die Liste der Auslassungen erwähnt %q nicht", requiredMention) } } // Und sie muss sagen, was zu tun ist — nicht nur, was fehlt. if !strings.Contains(joinedOmissions, "create-admin") { testInstance.Error("die Liste nennt den Weg zum ersten Zugang nicht") } } // TestSnapshotRejectsForeignFormatVersion prueft die Versionspruefung. // // Ein Satz aus einer kuenftigen Version wird abgelehnt, statt teilweise gelesen // zu werden: Eine halb wiederhergestellte Konfiguration ist schlimmer als gar // keine, weil sie arbeitsfaehig aussieht. func TestSnapshotRejectsForeignFormatVersion(testInstance *testing.T) { futureSnapshot := buildSampleSnapshot() futureSnapshot.FormatVersion = SnapshotFormatVersion + 1 if validationError := futureSnapshot.Validate(); validationError == nil { testInstance.Error("ein Satz aus einer künftigen Formatversion wurde angenommen") } emptyRepositorySnapshot := buildSampleSnapshot() emptyRepositorySnapshot.RepositoryID = "" if validationError := emptyRepositorySnapshot.Validate(); validationError == nil { testInstance.Error("ein Satz ohne Repository-Kennung wurde angenommen") } } // TestWriteSnapshotIsAtomicAndKeepsHistory prueft das Schreiben. func TestWriteSnapshotIsAtomicAndKeepsHistory(testInstance *testing.T) { repositoryRoot := testInstance.TempDir() snapshotPath, writeError := WriteSnapshot(repositoryRoot, buildSampleSnapshot()) if writeError != nil { testInstance.Fatalf("der Sicherungssatz ließ sich nicht schreiben: %v", writeError) } if _, statError := os.Stat(snapshotPath); statError != nil { testInstance.Fatalf("die feste Datei fehlt: %v", statError) } // Neben der festen Datei muss eine datierte Fassung liegen: Wer eine // irrtuemliche Konfigurationsaenderung zurueckdrehen will, braucht den Stand // davor. directoryEntries, readError := os.ReadDir(filepath.Join(repositoryRoot, filepath.FromSlash(SnapshotDirectory))) if readError != nil { testInstance.Fatalf("das Verzeichnis ließ sich nicht lesen: %v", readError) } datedFileCount := 0 for _, directoryEntry := range directoryEntries { if strings.HasPrefix(directoryEntry.Name(), "control-plane-") { datedFileCount++ } // Eine liegengebliebene Temp-Datei bedeutet, dass das atomare Schreiben // nicht aufgeraeumt hat. if strings.HasPrefix(directoryEntry.Name(), ".tmp-") { testInstance.Errorf("eine temporäre Datei blieb zurück: %s", directoryEntry.Name()) } } if datedFileCount != 1 { testInstance.Errorf("es liegen %d datierte Fassungen vor, erwartet wurde eine", datedFileCount) } } // TestReadSnapshotExplainsAMissingFile prueft die Fehlermeldung. // // Ein Repository ohne Sicherungssatz ist kein Totalschaden: Die // Wiederherstellungspunkte sind weiterhin nutzbar. Die Meldung muss das sagen, // sonst haelt der Betreiber im Ernstfall alles fuer verloren. func TestReadSnapshotExplainsAMissingFile(testInstance *testing.T) { _, readError := ReadSnapshot(testInstance.TempDir()) if readError == nil { testInstance.Fatal("ein fehlender Sicherungssatz wurde nicht gemeldet") } if !strings.Contains(readError.Error(), "wiederherstellungspunkte") { testInstance.Errorf("die Meldung sagt nicht, dass die Backups nutzbar bleiben: %v", readError) } } // TestReadSnapshotRejectsCorruptedFile prueft die Behandlung einer kaputten Datei. func TestReadSnapshotRejectsCorruptedFile(testInstance *testing.T) { repositoryRoot := testInstance.TempDir() snapshotDirectory := filepath.Join(repositoryRoot, filepath.FromSlash(SnapshotDirectory)) if directoryError := os.MkdirAll(snapshotDirectory, 0o700); directoryError != nil { testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", directoryError) } if writeError := os.WriteFile(filepath.Join(snapshotDirectory, SnapshotFileName), []byte("{ das ist kein json"), 0o600); writeError != nil { testInstance.Fatalf("die Datei ließ sich nicht schreiben: %v", writeError) } if _, readError := ReadSnapshot(repositoryRoot); readError == nil { testInstance.Error("ein beschädigter Sicherungssatz wurde angenommen") } } // TestDeriveIdentifierIsStable prueft die Kennungsableitung. // // Eine zweite Uebernahme desselben Repositorys muss dieselben Kennungen ergeben. // Mit Zufallswerten entstuenden bei jedem Lauf Dubletten, und niemand koennte // sagen, welcher Eintrag der richtige ist. func TestDeriveIdentifierIsStable(testInstance *testing.T) { repositoryIdentifier := uuidFromString(testInstance, "11111111-2222-3333-4444-555555555555") firstRun := deriveIdentifier(repositoryIdentifier, "backup", "run-abc") secondRun := deriveIdentifier(repositoryIdentifier, "backup", "run-abc") if firstRun != secondRun { testInstance.Errorf("dieselbe Eingabe ergab zwei Kennungen: %s und %s", firstRun, secondRun) } // Verschiedene Arten desselben Schluessels duerfen nicht kollidieren. if deriveIdentifier(repositoryIdentifier, "chain", "run-abc") == firstRun { testInstance.Error("Kette und Backup mit gleichem Schlüssel ergeben dieselbe Kennung") } // Und zwei Repositories mit gleichlautender Backupkennung ebenso wenig. otherRepository := uuidFromString(testInstance, "99999999-8888-7777-6666-555555555555") if deriveIdentifier(otherRepository, "backup", "run-abc") == firstRun { testInstance.Error("zwei Repositories mit gleicher Backupkennung kollidieren") } } // uuidFromString liest eine UUID oder bricht den Test ab. func uuidFromString(testInstance *testing.T, rawIdentifier string) uuid.UUID { testInstance.Helper() parsedIdentifier, parseError := uuid.Parse(rawIdentifier) if parseError != nil { testInstance.Fatalf("die Kennung %q ist unlesbar: %v", rawIdentifier, parseError) } return parsedIdentifier }