syncova-backup/packages/disasterrecovery/snapshot_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

280 lines
10 KiB
Go

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
}