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