syncova-backup/packages/providers/proxmox/provider_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

456 lines
17 KiB
Go

package proxmox
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"
"github.com/syncova/syncova/packages/providers"
)
// TestConnectRejectsPlainHTTP prüft, dass unverschlüsselte Endpunkte abgelehnt
// werden.
//
// Über eine unverschlüsselte Verbindung ginge das API-Token im Klartext auf die
// Leitung — es ist dauerhaft gültig und öffnet den gesamten Verbund.
func TestConnectRejectsPlainHTTP(testInstance *testing.T) {
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
_, clientError := NewClient(ClientOptions{
BaseURL: "http://pve.example:8006",
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim",
}, testLogger)
if clientError == nil {
testInstance.Fatal("ein unverschlüsselter endpunkt muss abgelehnt werden")
}
if !strings.Contains(clientError.Error(), "https") {
testInstance.Errorf("die Fehlermeldung nennt die Ursache nicht: %v", clientError)
}
}
// TestConnectRequiresCredentials prüft die Pflichtangaben.
func TestConnectRequiresCredentials(testInstance *testing.T) {
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
_, clientError := NewClient(ClientOptions{
BaseURL: "https://pve.example:8006",
APITokenID: "syncova@pve!backup",
}, testLogger)
if !errors.Is(clientError, ErrMissingCredentials) {
testInstance.Fatalf("fehlende Anmeldedaten müssen als solche gemeldet werden: %v", clientError)
}
}
// TestTLSFingerprintPinningAccepts prüft die Bindung an ein Zertifikat.
//
// Proxmox liefert ab Werk ein selbstsigniertes Zertifikat. Der übliche Ausweg
// ist, jede Prüfung abzuschalten — der Fingerabdruck ist die Alternative, die
// keine Sicherheit aufgibt.
func TestTLSFingerprintPinningAccepts(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
serverCertificate := fakeServer.httpServer.Certificate()
certificateDigest := sha256.Sum256(serverCertificate.Raw)
pinnedProvider, providerError := NewProvider(ProviderOptions{
ClientOptions: ClientOptions{
BaseURL: fakeServer.httpServer.URL,
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim",
// Bewusst in der Schreibweise, die Proxmox anzeigt: Großbuchstaben
// mit Doppelpunkten.
TLSFingerprintSHA256: strings.ToUpper(insertColons(hex.EncodeToString(certificateDigest[:]))),
},
}, testLogger)
if providerError != nil {
testInstance.Fatalf("der Provider ließ sich nicht bauen: %v", providerError)
}
if connectError := pinnedProvider.Connect(context.Background()); connectError != nil {
testInstance.Fatalf("die Verbindung mit hinterlegtem Fingerabdruck schlug fehl: %v", connectError)
}
}
// TestTLSFingerprintPinningRejectsWrongCertificate prüft die Abweisung.
func TestTLSFingerprintPinningRejectsWrongCertificate(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
pinnedProvider, providerError := NewProvider(ProviderOptions{
ClientOptions: ClientOptions{
BaseURL: fakeServer.httpServer.URL,
APITokenID: "syncova@pve!backup",
APITokenSecret: "geheim",
TLSFingerprintSHA256: strings.Repeat("aa", 32),
MaximumRetries: 1,
},
}, testLogger)
if providerError != nil {
testInstance.Fatalf("der Provider ließ sich nicht bauen: %v", providerError)
}
connectError := pinnedProvider.Connect(context.Background())
if connectError == nil {
testInstance.Fatal("ein fremdes Zertifikat muss die Verbindung verhindern")
}
}
// insertColons formatiert einen Fingerabdruck in der Proxmox-Schreibweise.
func insertColons(hexadecimalText string) string {
var builder strings.Builder
for characterIndex := 0; characterIndex < len(hexadecimalText); characterIndex += 2 {
if characterIndex > 0 {
builder.WriteByte(':')
}
builder.WriteString(hexadecimalText[characterIndex : characterIndex+2])
}
return builder.String()
}
// TestTokenNeverAppearsInErrors prüft, dass das Geheimnis nicht durchsickert.
//
// Ein Token in einer Fehlermeldung landet im Protokoll, im Ticketsystem und in
// der Oberfläche (PROMPT.md §141).
func TestTokenNeverAppearsInErrors(testInstance *testing.T) {
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
const tokenSecret = "streng-geheimes-token-12345" // secretscan:erlaubt: erfundener Testwert
unreachableProvider, providerError := NewProvider(ProviderOptions{
ClientOptions: ClientOptions{
BaseURL: "https://127.0.0.1:1/",
APITokenID: "syncova@pve!backup",
APITokenSecret: tokenSecret,
RequestTimeout: time.Second,
MaximumRetries: 1,
},
}, testLogger)
if providerError != nil {
testInstance.Fatalf("der Provider ließ sich nicht bauen: %v", providerError)
}
connectError := unreachableProvider.Connect(context.Background())
if connectError == nil {
testInstance.Fatal("ein nicht erreichbarer Endpunkt muss einen Fehler ergeben")
}
if strings.Contains(connectError.Error(), tokenSecret) {
testInstance.Fatalf("das Token steht in der Fehlermeldung: %v", connectError)
}
}
// TestDiscoveryFindsClustersHostsAndGuests prüft die Erfassung.
func TestDiscoveryFindsClustersHostsAndGuests(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
discoveryContext := context.Background()
discoveredClusters, clusterError := testProvider.ListClusters(discoveryContext)
if clusterError != nil {
testInstance.Fatalf("die Verbunderfassung schlug fehl: %v", clusterError)
}
if len(discoveredClusters) != 1 || discoveredClusters[0].Name != "syncova-lab" {
testInstance.Fatalf("der Verbund wurde nicht erkannt: %+v", discoveredClusters)
}
if !discoveredClusters[0].Quorate {
testInstance.Error("der Verbund wurde fälschlich als nicht beschlussfähig gemeldet")
}
discoveredHosts, hostError := testProvider.ListHosts(discoveryContext, "syncova-lab")
if hostError != nil {
testInstance.Fatalf("die Knotenerfassung schlug fehl: %v", hostError)
}
if len(discoveredHosts) != 3 {
testInstance.Fatalf("es wurden 3 Knoten erwartet, gefunden wurden %d", len(discoveredHosts))
}
// Der abgeschaltete Knoten muss als solcher erscheinen und nicht fehlen:
// eine Wiederherstellung dorthin scheiterte sonst erst nach Stunden.
if discoveredHosts[2].Online {
testInstance.Error("der abgeschaltete Knoten wurde als erreichbar gemeldet")
}
discoveredGuests, guestError := testProvider.ListVMs(discoveryContext, "")
if guestError != nil {
testInstance.Fatalf("die Gasterfassung schlug fehl: %v", guestError)
}
// Drei Gäste — die Vorlage darf nicht dabei sein.
if len(discoveredGuests) != 3 {
testInstance.Fatalf("es wurden 3 Gäste erwartet, gefunden wurden %d: %+v", len(discoveredGuests), discoveredGuests)
}
for _, discoveredGuest := range discoveredGuests {
if strings.Contains(discoveredGuest.Name, "vorlage") {
testInstance.Errorf("eine Vorlage wurde als sicherbarer Gast gemeldet: %s", discoveredGuest.Name)
}
}
// Der Container muss als Container erkannt werden.
var foundContainer bool
for _, discoveredGuest := range discoveredGuests {
if discoveredGuest.Identifier == "lxc/200" && discoveredGuest.GuestType == providers.GuestTypeContainer {
foundContainer = true
}
}
if !foundContainer {
testInstance.Error("der LXC-Container wurde nicht als Container erkannt")
}
}
// TestListVMsFiltersByHost prüft die Einschränkung auf einen Knoten.
func TestListVMsFiltersByHost(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
guestsOnSecondNode, listError := testProvider.ListVMs(context.Background(), "pve-02")
if listError != nil {
testInstance.Fatalf("die Gasterfassung schlug fehl: %v", listError)
}
if len(guestsOnSecondNode) != 1 || guestsOnSecondNode[0].Identifier != "qemu/101" {
testInstance.Fatalf("die Einschränkung auf pve-02 wirkte nicht: %+v", guestsOnSecondNode)
}
}
// TestGetVMDisksSeparatesRealDisksFromMedia ist der wichtigste Erfassungstest.
//
// Zwei Fehler wären hier folgenschwer: ein eingelegtes ISO-Abbild als Platte zu
// sichern, und eine mit backup=0 ausgenommene Platte nicht als solche zu
// erkennen — im zweiten Fall hielte man eine unvollständige Maschine für
// vollständig.
func TestGetVMDisksSeparatesRealDisksFromMedia(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
discoveredDisks, diskError := testProvider.GetVMDisks(context.Background(), "qemu/100")
if diskError != nil {
testInstance.Fatalf("die Plattenerfassung schlug fehl: %v", diskError)
}
disksByIdentifier := make(map[string]providers.Disk, len(discoveredDisks))
for _, discoveredDisk := range discoveredDisks {
disksByIdentifier[discoveredDisk.Identifier] = discoveredDisk
}
// Das ISO-Abbild an ide2 ist keine Platte.
if _, hasCDROM := disksByIdentifier["ide2"]; hasCDROM {
testInstance.Error("ein eingelegtes ISO-Abbild wurde als Platte erfasst")
}
systemDisk, hasSystemDisk := disksByIdentifier["scsi0"]
if !hasSystemDisk {
testInstance.Fatal("die Systemplatte wurde nicht erfasst")
}
if systemDisk.SizeBytes != 32<<30 {
testInstance.Errorf("die Größe wurde als %d Byte gelesen, erwartet waren %d", systemDisk.SizeBytes, int64(32)<<30)
}
if systemDisk.StorageID != "local-lvm" {
testInstance.Errorf("der Speicher wurde als %q gelesen", systemDisk.StorageID)
}
if systemDisk.ExcludedFromBackup {
testInstance.Error("die Systemplatte wurde fälschlich als von der Sicherung ausgenommen gemeldet")
}
dataDisk, hasDataDisk := disksByIdentifier["scsi1"]
if !hasDataDisk {
testInstance.Fatal("die Datenplatte wurde nicht erfasst")
}
// Der entscheidende Punkt: backup=0 muss sichtbar werden.
if !dataDisk.ExcludedFromBackup {
testInstance.Fatal("eine mit backup=0 ausgenommene Platte wurde nicht als solche erkannt")
}
// Die abgehängte Platte trägt Daten und gehört ins Backup.
if _, hasUnused := disksByIdentifier["unused0"]; !hasUnused {
testInstance.Error("eine abgehängte Platte wurde übergangen, obwohl sie Daten enthält")
}
// Die EFI-Platte ist klein, aber ohne sie startet eine UEFI-Maschine nicht.
if _, hasEFI := disksByIdentifier["efidisk0"]; !hasEFI {
testInstance.Error("die EFI-Platte wurde übergangen; eine UEFI-Maschine startete ohne sie nicht")
}
}
// TestGetVMDisksParsesFractionalSizes prüft Größen wie "1.5T".
func TestGetVMDisksParsesFractionalSizes(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
discoveredDisks, diskError := testProvider.GetVMDisks(context.Background(), "qemu/101")
if diskError != nil {
testInstance.Fatalf("die Plattenerfassung schlug fehl: %v", diskError)
}
if len(discoveredDisks) != 1 {
testInstance.Fatalf("es wurde eine Platte erwartet, gefunden wurden %d", len(discoveredDisks))
}
expectedSize := int64(1.5 * float64(int64(1)<<40))
if discoveredDisks[0].SizeBytes != expectedSize {
testInstance.Errorf("die Größe 1.5T wurde als %d Byte gelesen, erwartet waren %d",
discoveredDisks[0].SizeBytes, expectedSize)
}
}
// TestGetVMDisksHandlesContainerMountpoints prüft LXC.
func TestGetVMDisksHandlesContainerMountpoints(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
discoveredDisks, diskError := testProvider.GetVMDisks(context.Background(), "lxc/200")
if diskError != nil {
testInstance.Fatalf("die Plattenerfassung schlug fehl: %v", diskError)
}
disksByIdentifier := make(map[string]providers.Disk, len(discoveredDisks))
for _, discoveredDisk := range discoveredDisks {
disksByIdentifier[discoveredDisk.Identifier] = discoveredDisk
}
if _, hasRootFS := disksByIdentifier["rootfs"]; !hasRootFS {
testInstance.Error("das Wurzeldateisystem des Containers wurde nicht erfasst")
}
if _, hasMountpoint := disksByIdentifier["mp0"]; !hasMountpoint {
testInstance.Error("ein Einhängepunkt des Containers wurde nicht erfasst")
}
}
// TestGetVMMetaDataPreservesConfiguration prüft die Konfigurationserfassung.
func TestGetVMMetaDataPreservesConfiguration(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
guestMetadata, metadataError := testProvider.GetVMMetaData(context.Background(), "qemu/100")
if metadataError != nil {
testInstance.Fatalf("die Konfigurationserfassung schlug fehl: %v", metadataError)
}
if guestMetadata.FirmwareType != "ovmf" {
testInstance.Errorf("die Firmware wurde als %q gelesen, erwartet war ovmf", guestMetadata.FirmwareType)
}
if !guestMetadata.GuestAgentEnabled {
testInstance.Error("der eingerichtete Gastdienst wurde nicht erkannt")
}
if len(guestMetadata.NetworkInterfaces) != 2 {
testInstance.Fatalf("es wurden 2 Netzwerkkarten erwartet, gefunden wurden %d", len(guestMetadata.NetworkInterfaces))
}
firstInterface := guestMetadata.NetworkInterfaces[0]
// Die MAC-Adresse muss erhalten bleiben: Lizenzbindungen und
// DHCP-Reservierungen hängen daran.
if firstInterface.MACAddress != "AA:BB:CC:DD:EE:01" {
testInstance.Errorf("die MAC-Adresse wurde als %q gelesen", firstInterface.MACAddress)
}
if firstInterface.Model != "virtio" {
testInstance.Errorf("das Kartenmodell wurde als %q gelesen", firstInterface.Model)
}
if firstInterface.VLANTag != 42 {
testInstance.Errorf("das VLAN-Etikett wurde als %d gelesen", firstInterface.VLANTag)
}
expectedBootOrder := []string{"scsi0", "net0"}
if len(guestMetadata.BootOrder) != len(expectedBootOrder) {
testInstance.Fatalf("die Startreihenfolge wurde als %v gelesen", guestMetadata.BootOrder)
}
// Die Rohkonfiguration muss wortgetreu erhalten bleiben — auch die Felder,
// die wir heute nicht deuten.
if guestMetadata.RawConfiguration["scsi0"] != "local-lvm:vm-100-disk-0,size=32G,ssd=1" {
testInstance.Errorf("die Rohkonfiguration wurde verändert: %q", guestMetadata.RawConfiguration["scsi0"])
}
}
// TestGetVMMetaDataDefaultsFirmwareForQEMU prüft den weggelassenen Standard.
//
// Proxmox lässt "bios" weg, wenn SeaBIOS gilt. Das Feld leer zu lassen wäre
// missverständlich: eine mit UEFI wiederhergestellte Maschine startet nicht.
func TestGetVMMetaDataDefaultsFirmwareForQEMU(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
guestMetadata, metadataError := testProvider.GetVMMetaData(context.Background(), "qemu/101")
if metadataError != nil {
testInstance.Fatalf("die Konfigurationserfassung schlug fehl: %v", metadataError)
}
if guestMetadata.FirmwareType != "seabios" {
testInstance.Errorf("ohne Angabe muss seabios gelten, gelesen wurde %q", guestMetadata.FirmwareType)
}
if guestMetadata.GuestAgentEnabled {
testInstance.Error("ohne agent=1 darf kein Gastdienst angenommen werden")
}
}
// TestGetVMMetaDataParsesLegacyBootOrder prüft die alte Schreibweise.
func TestGetVMMetaDataParsesLegacyBootOrder(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
guestMetadata, metadataError := testProvider.GetVMMetaData(context.Background(), "qemu/101")
if metadataError != nil {
testInstance.Fatalf("die Konfigurationserfassung schlug fehl: %v", metadataError)
}
expectedOrder := []string{"cdrom", "disk", "network"}
if len(guestMetadata.BootOrder) != len(expectedOrder) {
testInstance.Fatalf("die alte Startreihenfolge 'cdn' wurde als %v gelesen", guestMetadata.BootOrder)
}
}
// TestGuestNotFoundIsReportedClearly prüft die Meldung eines fehlenden Gasts.
func TestGuestNotFoundIsReportedClearly(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
_, infoError := testProvider.GetVMInfo(context.Background(), "qemu/999")
if !errors.Is(infoError, providers.ErrGuestNotFound) {
testInstance.Fatalf("ein fehlender Gast muss als solcher gemeldet werden: %v", infoError)
}
}
// TestInvalidGuestIdentifierIsRejected prüft die Kennungsprüfung.
func TestInvalidGuestIdentifierIsRejected(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
invalidIdentifiers := []string{"100", "vmware/100", "qemu/abc", "qemu/0", ""}
for _, invalidIdentifier := range invalidIdentifiers {
if _, diskError := testProvider.GetVMDisks(context.Background(), invalidIdentifier); diskError == nil {
testInstance.Errorf("die ungültige Kennung %q wurde angenommen", invalidIdentifier)
}
}
}