syncova-backup/packages/hypervisor/store_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

381 lines
14 KiB
Go

package hypervisor
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/syncova/syncova/packages/platform/crypto"
)
// TestClusterInputValidation prueft die Eingangspruefung.
//
// Sie ist der einzige Punkt, an dem ein unbrauchbarer Verbund auffaellt, ohne
// dass jemand darunter leidet: Ein Verbund ohne Zugriffsweg auf die Archive
// laesst sich sonst einrichten, meldet „erreichbar" und liefert bei der ersten
// Sicherung um zwei Uhr nachts kein einziges Byte.
func TestClusterInputValidation(testInstance *testing.T) {
validInput := ClusterInput{
Name: "pve-verbund",
APIEndpoint: "https://pve.example:8006",
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
BackupStorageID: "local",
ArchiveTransport: TransportLocal,
ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"},
}
if validationError := validInput.Validate(); validationError != nil {
testInstance.Fatalf("eine gültige Eingabe wurde abgelehnt: %v", validationError)
}
testCases := []struct {
name string
modify func(*ClusterInput)
expectedInMsg string
}{
{
name: "ohne Namen",
modify: func(input *ClusterInput) { input.Name = "" },
expectedInMsg: "namen",
},
{
// http:// ist ein Tippfehler mit Folgen: Das API-Token ginge im
// Klartext über das Netz.
name: "unverschlüsselte Adresse",
modify: func(input *ClusterInput) { input.APIEndpoint = "http://pve.example:8006" },
expectedInMsg: "klartext",
},
{
name: "ohne Token",
modify: func(input *ClusterInput) { input.APITokenSecret = "" },
expectedInMsg: "api-token",
},
{
name: "ohne Sicherungsspeicher",
modify: func(input *ClusterInput) { input.BackupStorageID = "" },
expectedInMsg: "speicher",
},
{
name: "lokaler Weg ohne Zuordnung",
modify: func(input *ClusterInput) { input.ArchiveMountRoots = nil },
expectedInMsg: "zuordnung",
},
{
name: "lokaler Weg mit relativem Pfad",
modify: func(input *ClusterInput) {
input.ArchiveMountRoots = map[string]string{"local": "var/lib/vz"}
},
expectedInMsg: "absolut",
},
{
name: "unbekannter Zugriffsweg",
modify: func(input *ClusterInput) { input.ArchiveTransport = "ftp" },
expectedInMsg: "unbekannt",
},
}
for _, testCase := range testCases {
testInstance.Run(testCase.name, func(subTest *testing.T) {
modifiedInput := validInput
testCase.modify(&modifiedInput)
validationError := modifiedInput.Validate()
if validationError == nil {
subTest.Fatal("die fehlerhafte Eingabe wurde angenommen")
}
if !strings.Contains(validationError.Error(), testCase.expectedInMsg) {
subTest.Errorf("die Meldung erklärt den Fehler nicht: %v", validationError)
}
})
}
}
// TestSSHClusterRequiresHostFingerprints ist der Sicherheitstest der Einrichtung.
//
// Ein SSH-Zugriffsweg ohne hinterlegte Wirtsschluessel nimmt jeden Server an,
// der auf die Adresse antwortet. Der Angreifer lieferte dann das Archiv, das
// Syncova fuer ein Backup haelt — und niemand saehe einen Fehler.
func TestSSHClusterRequiresHostFingerprints(testInstance *testing.T) {
sshInput := ClusterInput{
Name: "pve-ssh",
APIEndpoint: "https://pve.example:8006",
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
BackupStorageID: "local",
ArchiveTransport: TransportSSH,
SSHUsername: "syncova",
SSHPrivateKeyPEM: "-----BEGIN OPENSSH PRIVATE KEY-----\n", // secretscan:erlaubt: Platzhalter ohne Schluesselmaterial
}
validationError := sshInput.Validate()
if validationError == nil {
testInstance.Fatal("ein SSH-Verbund ohne Wirtsschlüssel wurde angenommen")
}
if !strings.Contains(validationError.Error(), "zwischenangriff") {
testInstance.Errorf("die Meldung nennt den Grund nicht: %v", validationError)
}
// Mit Fingerabdruck geht es durch.
sshInput.SSHHostFingerprints = map[string]string{"pve-01": "SHA256:abc"}
if acceptedError := sshInput.Validate(); acceptedError != nil {
testInstance.Errorf("ein vollständiger SSH-Verbund wurde abgelehnt: %v", acceptedError)
}
}
// TestClusterRoundTripKeepsSecretsEncrypted prueft Ablage und Rueckgabe.
//
// Der entscheidende Teil steht am Ende: Das API-Token darf in der Tabelle nicht
// im Klartext auffindbar sein. Wer die Datenbank sichert, sicherte es sonst
// gleich mit (PROMPT.md §140).
func TestClusterRoundTripKeepsSecretsEncrypted(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := newTestStore(testInstance, connectionPool)
const tokenSecret = "streng-geheimer-tokenwert-1234" // secretscan:erlaubt: erfundener Testwert
createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{
Name: "verbund-" + uuid.NewString(),
APIEndpoint: "https://pve.example:8006",
APITokenID: "syncova@pve!backup",
APITokenSecret: tokenSecret,
TLSFingerprint: "aa:bb:cc",
BackupStorageID: "local",
ArchiveTransport: TransportLocal,
ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"},
})
if createError != nil {
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
}
testInstance.Cleanup(func() {
_ = testStore.DeleteCluster(context.Background(), createdCluster.ID)
})
if createdCluster.Status != StatusUnknown {
testInstance.Errorf("ein neuer Verbund gilt als %q statt als ungeprüft", createdCluster.Status)
}
readCluster, readError := testStore.GetCluster(context.Background(), createdCluster.ID)
if readError != nil {
testInstance.Fatalf("der Verbund ließ sich nicht lesen: %v", readError)
}
if readCluster.ArchiveMountRoots["local"] != "/var/lib/vz" {
testInstance.Errorf("die Speicherzuordnung ging verloren: %v", readCluster.ArchiveMountRoots)
}
credentials, credentialError := testStore.LoadCredentials(context.Background(), createdCluster.ID)
if credentialError != nil {
testInstance.Fatalf("die Zugangsdaten ließen sich nicht lesen: %v", credentialError)
}
if credentials.APITokenSecret != tokenSecret {
testInstance.Error("das entschlüsselte Token stimmt nicht mit dem abgelegten überein")
}
// Der Nachweis: Der Klartext steht nirgends in der Zeile.
var storedRow string
if scanError := connectionPool.QueryRow(context.Background(),
`SELECT proxmox_clusters::text FROM proxmox_clusters WHERE id = $1`,
createdCluster.ID).Scan(&storedRow); scanError != nil {
testInstance.Fatalf("die Zeile ließ sich nicht lesen: %v", scanError)
}
if strings.Contains(storedRow, tokenSecret) {
testInstance.Error("das API-Token steht im Klartext in der Datenbank")
}
}
// TestRecordConnectionResultKeepsLastSeen ist der Regressionstest zum Zeitpunkt.
//
// last_seen_at wird nur bei Erfolg fortgeschrieben. Bei jedem Versuch zu setzen
// machte aus „zuletzt erreicht" ein „zuletzt versucht" — und ein seit Wochen
// toter Verbund saehe frisch aus.
func TestRecordConnectionResultKeepsLastSeen(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := newTestStore(testInstance, connectionPool)
createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{
Name: "verbund-" + uuid.NewString(),
APIEndpoint: "https://pve.example:8006",
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
BackupStorageID: "local",
ArchiveTransport: TransportLocal,
ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"},
})
if createError != nil {
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
}
testInstance.Cleanup(func() {
_ = testStore.DeleteCluster(context.Background(), createdCluster.ID)
})
if recordError := testStore.RecordConnectionResult(context.Background(),
createdCluster.ID, StatusReachable, nil); recordError != nil {
testInstance.Fatalf("das Ergebnis ließ sich nicht vermerken: %v", recordError)
}
reachableCluster, _ := testStore.GetCluster(context.Background(), createdCluster.ID)
if reachableCluster.LastSeenAt == nil {
testInstance.Fatal("nach einer erfolgreichen Verbindung fehlt der Zeitpunkt")
}
successTime := *reachableCluster.LastSeenAt
if recordError := testStore.RecordConnectionResult(context.Background(),
createdCluster.ID, StatusUnauthorized, errors.New("token abgelehnt")); recordError != nil {
testInstance.Fatalf("das Ergebnis ließ sich nicht vermerken: %v", recordError)
}
failedCluster, _ := testStore.GetCluster(context.Background(), createdCluster.ID)
if failedCluster.Status != StatusUnauthorized {
testInstance.Errorf("der Zustand ist %q", failedCluster.Status)
}
if failedCluster.LastError == "" {
testInstance.Error("der Grund des Fehlschlags wurde nicht vermerkt")
}
if failedCluster.LastSeenAt == nil || !failedCluster.LastSeenAt.Equal(successTime) {
testInstance.Error("ein fehlgeschlagener Versuch hat den Zeitpunkt der letzten Verbindung verschoben")
}
}
// TestMarkMissingGuestsHandlesEmptyList ist der Regressionstest zu `ANY(NULL)`.
//
// Ein Verbund ohne Gaeste — etwa weil alle geloescht wurden — muss **alle**
// bekannten Gaeste als fehlend vermerken. Mit einer NULL statt eines leeren
// Arrays waere die Bedingung niemals wahr, und es fiele niemandem auf.
func TestMarkMissingGuestsHandlesEmptyList(testInstance *testing.T) {
connectionPool := connectTestDatabase(testInstance)
testStore := newTestStore(testInstance, connectionPool)
createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{
Name: "verbund-" + uuid.NewString(),
APIEndpoint: "https://pve.example:8006",
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
BackupStorageID: "local",
ArchiveTransport: TransportLocal,
ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"},
})
if createError != nil {
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
}
testInstance.Cleanup(func() {
_ = testStore.DeleteCluster(context.Background(), createdCluster.ID)
})
if _, insertError := connectionPool.Exec(context.Background(),
`INSERT INTO virtual_machines (cluster_id, provider_vm_id, name, guest_kind)
VALUES ($1, 'qemu/100', 'web-01', 'qemu'), ($1, 'qemu/101', 'db-01', 'qemu')`,
createdCluster.ID); insertError != nil {
testInstance.Fatalf("die Gäste ließen sich nicht anlegen: %v", insertError)
}
missingCount, markError := testStore.markMissingGuests(context.Background(), createdCluster.ID, nil)
if markError != nil {
testInstance.Fatalf("die fehlenden Gäste ließen sich nicht vermerken: %v", markError)
}
if missingCount != 2 {
testInstance.Errorf("es wurden %d Gäste als fehlend vermerkt statt zwei", missingCount)
}
// Gelöscht wird nichts: Die Zuordnung zu vorhandenen Backups muss bleiben.
machineList, listError := testStore.ListVirtualMachines(context.Background(), &createdCluster.ID)
if listError != nil {
testInstance.Fatalf("der Bestand ließ sich nicht lesen: %v", listError)
}
if len(machineList) != 2 {
testInstance.Fatalf("es sind %d Gäste im Bestand statt zwei", len(machineList))
}
for _, singleMachine := range machineList {
if singleMachine.MissingSince == nil {
testInstance.Errorf("der Gast %q gilt nicht als fehlend", singleMachine.Name)
}
}
// Ein zweiter Lauf darf den Zeitpunkt nicht verschieben: Sonst stünde bei
// einem seit Wochen fehlenden Gast immer „seit gerade eben".
secondCount, secondError := testStore.markMissingGuests(context.Background(), createdCluster.ID, nil)
if secondError != nil {
testInstance.Fatalf("der zweite Lauf schlug fehl: %v", secondError)
}
if secondCount != 0 {
testInstance.Errorf("der zweite Lauf vermerkte %d Gäste erneut", secondCount)
}
}
// newTestStore baut den Speicher mit einem Testschlüssel.
func newTestStore(testInstance *testing.T, connectionPool *pgxpool.Pool) *Store {
testInstance.Helper()
secretStore, storeError := crypto.NewLocalSecretStore(map[string][]byte{
"v1": []byte("0123456789abcdef0123456789abcdef"), // secretscan:erlaubt: erfundener Testwert
}, "v1")
if storeError != nil {
testInstance.Fatalf("der Schlüsselspeicher ließ sich nicht bauen: %v", storeError)
}
builtStore, buildError := NewStore(connectionPool, secretStore)
if buildError != nil {
testInstance.Fatalf("der Speicher ließ sich nicht bauen: %v", buildError)
}
return builtStore
}
// connectTestDatabase öffnet die Testdatenbank.
//
// Ohne erreichbare Datenbank werden die Tests übersprungen statt zu scheitern:
// Ein rot leuchtender Testlauf auf einem Rechner ohne Docker gewöhnt nur das
// Wegschauen an.
func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool {
testInstance.Helper()
connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL")
if connectionString == "" {
connectionString = os.Getenv("SYNCOVA_DATABASE_URL")
}
if connectionString == "" {
testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden übersprungen")
}
connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelConnect()
connectionPool, poolError := pgxpool.New(connectContext, connectionString)
if poolError != nil {
testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError)
}
if pingError := connectionPool.Ping(connectContext); pingError != nil {
connectionPool.Close()
testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError)
}
testInstance.Cleanup(connectionPool.Close)
return connectionPool
}