package proxmox import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" ) func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) cfg := ClientConfig{ Host: server.URL, TokenName: "user@pve!token", TokenValue: "secret", VerifySSL: false, Timeout: 2 * time.Second, } client, err := NewClient(cfg) if err != nil { t.Fatalf("NewClient failed: %v", err) } return client } func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { t.Helper() w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(payload); err != nil { t.Fatalf("encode json: %v", err) } } func TestClientStorageAndTasks(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api2/json/storage": writeJSON(t, w, map[string]interface{}{ "data": []Storage{{Storage: "local", Type: "dir"}}, }) case "/api2/json/nodes": writeJSON(t, w, map[string]interface{}{ "data": []Node{{Node: "node1", Status: "online"}, {Node: "node2", Status: "offline"}}, }) case "/api2/json/nodes/node1/tasks": writeJSON(t, w, map[string]interface{}{ "data": []Task{ {UPID: "1", Type: "vzdump"}, {UPID: "2", Type: "other"}, }, }) default: http.NotFound(w, r) } }) ctx := context.Background() storage, err := client.GetAllStorage(ctx) if err != nil { t.Fatalf("GetAllStorage error: %v", err) } if len(storage) != 1 || storage[0].Storage != "local" { t.Fatalf("unexpected storage: %+v", storage) } tasks, err := client.GetBackupTasks(ctx) if err != nil { t.Fatalf("GetBackupTasks error: %v", err) } if len(tasks) != 1 || tasks[0].Type != "vzdump" { t.Fatalf("unexpected tasks: %+v", tasks) } } func TestClientSnapshotsAndContent(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api2/json/nodes/node1/storage/local/content": writeJSON(t, w, map[string]interface{}{ "data": []StorageContent{ {Volid: "backup1", Content: "backup"}, {Volid: "iso1", Content: "iso"}, {Volid: "tmpl1", Content: "vztmpl"}, }, }) case "/api2/json/nodes/node1/qemu/100/snapshot": writeJSON(t, w, map[string]interface{}{ "data": []Snapshot{{Name: "current"}, {Name: "snap1"}}, }) case "/api2/json/nodes/node1/lxc/101/snapshot": writeJSON(t, w, map[string]interface{}{ "data": []Snapshot{{Name: "current"}, {Name: "snap2"}}, }) default: http.NotFound(w, r) } }) ctx := context.Background() content, err := client.GetStorageContent(ctx, "node1", "local") if err != nil { t.Fatalf("GetStorageContent error: %v", err) } if len(content) != 2 { t.Fatalf("expected 2 backup-related items, got %d", len(content)) } snaps, err := client.GetVMSnapshots(ctx, "node1", 100) if err != nil { t.Fatalf("GetVMSnapshots error: %v", err) } if len(snaps) != 1 || snaps[0].Name != "snap1" || snaps[0].VMID != 100 { t.Fatalf("unexpected VM snapshots: %+v", snaps) } ctSnaps, err := client.GetContainerSnapshots(ctx, "node1", 101) if err != nil { t.Fatalf("GetContainerSnapshots error: %v", err) } if len(ctSnaps) != 1 || ctSnaps[0].Name != "snap2" || ctSnaps[0].VMID != 101 { t.Fatalf("unexpected container snapshots: %+v", ctSnaps) } } func TestClientClusterAndAgentInfo(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api2/json/cluster/status": writeJSON(t, w, map[string]interface{}{ "data": []ClusterStatus{ {Type: "cluster", Name: "prod"}, {Type: "node", Name: "node1"}, }, }) case "/api2/json/nodes/node1/qemu/100/config": writeJSON(t, w, map[string]interface{}{ "data": map[string]interface{}{"name": "vm1"}, }) case "/api2/json/nodes/node1/qemu/100/agent/get-osinfo": writeJSON(t, w, map[string]interface{}{ "data": map[string]interface{}{"id": "linux"}, }) case "/api2/json/nodes/node1/qemu/100/agent/info": writeJSON(t, w, map[string]interface{}{ "data": map[string]interface{}{ "result": map[string]interface{}{ "version": "1.2.3", }, }, }) case "/api2/json/nodes/node1/qemu/100/agent/network-get-interfaces": writeJSON(t, w, map[string]interface{}{ "data": map[string]interface{}{ "result": []VMNetworkInterface{{Name: "eth0"}}, }, }) default: http.NotFound(w, r) } }) ctx := context.Background() status, err := client.GetClusterStatus(ctx) if err != nil { t.Fatalf("GetClusterStatus error: %v", err) } if len(status) != 2 { t.Fatalf("unexpected cluster status: %+v", status) } member, err := client.IsClusterMember(ctx) if err != nil { t.Fatalf("IsClusterMember error: %v", err) } if !member { t.Fatal("expected cluster membership") } config, err := client.GetVMConfig(ctx, "node1", 100) if err != nil { t.Fatalf("GetVMConfig error: %v", err) } if config["name"] != "vm1" { t.Fatalf("unexpected vm config: %+v", config) } osInfo, err := client.GetVMAgentInfo(ctx, "node1", 100) if err != nil { t.Fatalf("GetVMAgentInfo error: %v", err) } if osInfo["id"] != "linux" { t.Fatalf("unexpected agent info: %+v", osInfo) } version, err := client.GetVMAgentVersion(ctx, "node1", 100) if err != nil { t.Fatalf("GetVMAgentVersion error: %v", err) } if version != "1.2.3" { t.Fatalf("unexpected agent version: %q", version) } ifaces, err := client.GetVMNetworkInterfaces(ctx, "node1", 100) if err != nil { t.Fatalf("GetVMNetworkInterfaces error: %v", err) } if len(ifaces) != 1 || ifaces[0].Name != "eth0" { t.Fatalf("unexpected interfaces: %+v", ifaces) } } func TestClientVMNetworkInterfacesSingleObjectResult(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/network-get-interfaces": writeJSON(t, w, map[string]interface{}{ "data": map[string]interface{}{ "result": map[string]interface{}{ "name": "Ethernet0", "hardware-address": "aa:bb:cc:dd:ee:ff", "ip-addresses": []map[string]interface{}{ {"ip-address": "192.168.1.20", "prefix": 24}, }, }, }, }) default: http.NotFound(w, r) } }) ifaces, err := client.GetVMNetworkInterfaces(context.Background(), "node1", 100) if err != nil { t.Fatalf("GetVMNetworkInterfaces error: %v", err) } if len(ifaces) != 1 { t.Fatalf("expected 1 interface, got %d", len(ifaces)) } if ifaces[0].Name != "Ethernet0" { t.Fatalf("unexpected interface: %+v", ifaces[0]) } if len(ifaces[0].IPAddresses) != 1 || ifaces[0].IPAddresses[0].Address != "192.168.1.20" { t.Fatalf("unexpected interface addresses: %+v", ifaces[0].IPAddresses) } } func TestClientVMNetworkInterfacesSkipsMalformedEntries(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/network-get-interfaces": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "data": { "result": [ {"name": "eth0", "hardware-address": "aa:bb:cc:dd:ee:ff"}, {"name": {"bad":"shape"}}, {"name": "eth1", "ip-addresses": [{"ip-address": "10.0.0.5", "prefix": 24}]} ] } }`)) default: http.NotFound(w, r) } }) ifaces, err := client.GetVMNetworkInterfaces(context.Background(), "node1", 100) if err != nil { t.Fatalf("GetVMNetworkInterfaces error: %v", err) } if len(ifaces) != 2 { t.Fatalf("expected 2 valid interfaces, got %d: %+v", len(ifaces), ifaces) } if ifaces[0].Name != "eth0" || ifaces[1].Name != "eth1" { t.Fatalf("unexpected interfaces: %+v", ifaces) } } func TestClientVMNetworkInterfacesSkipsMalformedAddressEntries(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/network-get-interfaces": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "data": { "result": [ { "name": "eth0", "ip-addresses": [ {"ip-address": "192.168.10.20", "prefix": 24}, {"ip-address": {"bad":"shape"}, "prefix": "24"}, {"ip-address": "2001:db8::10", "prefix": "64"} ] } ] } }`)) default: http.NotFound(w, r) } }) ifaces, err := client.GetVMNetworkInterfaces(context.Background(), "node1", 100) if err != nil { t.Fatalf("GetVMNetworkInterfaces error: %v", err) } if len(ifaces) != 1 { t.Fatalf("expected 1 valid interface, got %d: %+v", len(ifaces), ifaces) } if len(ifaces[0].IPAddresses) != 2 { t.Fatalf("expected 2 valid addresses, got %+v", ifaces[0].IPAddresses) } if ifaces[0].IPAddresses[0].Address != "192.168.10.20" || ifaces[0].IPAddresses[0].Prefix != 24 { t.Fatalf("unexpected first address: %+v", ifaces[0].IPAddresses[0]) } if ifaces[0].IPAddresses[1].Address != "2001:db8::10" || ifaces[0].IPAddresses[1].Prefix != 64 { t.Fatalf("unexpected second address: %+v", ifaces[0].IPAddresses[1]) } } func TestClientVMNetworkInterfacesAcceptsSingleAddressObject(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/network-get-interfaces": writeJSON(t, w, map[string]interface{}{ "data": map[string]interface{}{ "result": []map[string]interface{}{ { "name": "eth0", "ip-addresses": map[string]interface{}{ "ip-address": "10.1.2.3", "prefix": 16, }, }, }, }, }) default: http.NotFound(w, r) } }) ifaces, err := client.GetVMNetworkInterfaces(context.Background(), "node1", 100) if err != nil { t.Fatalf("GetVMNetworkInterfaces error: %v", err) } if len(ifaces) != 1 || len(ifaces[0].IPAddresses) != 1 { t.Fatalf("unexpected interfaces: %+v", ifaces) } if ifaces[0].IPAddresses[0].Address != "10.1.2.3" || ifaces[0].IPAddresses[0].Prefix != 16 { t.Fatalf("unexpected address: %+v", ifaces[0].IPAddresses[0]) } } func TestClientStatusAndResources(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/status/current": writeJSON(t, w, map[string]interface{}{ "data": map[string]interface{}{"status": "running"}, }) case "/api2/json/nodes/node1/lxc/101/status/current": writeJSON(t, w, map[string]interface{}{ "data": map[string]interface{}{"status": "running", "vmid": 101}, }) case "/api2/json/cluster/resources": writeJSON(t, w, map[string]interface{}{ "data": []ClusterResource{{ID: "qemu/100", Type: "vm", VMID: 100}}, }) case "/api2/json/nodes/node1/lxc/101/interfaces": writeJSON(t, w, map[string]interface{}{ "data": []ContainerInterface{{Name: "eth0", HWAddr: "aa:bb"}}, }) default: http.NotFound(w, r) } }) ctx := context.Background() vmStatus, err := client.GetVMStatus(ctx, "node1", 100) if err != nil { t.Fatalf("GetVMStatus error: %v", err) } if vmStatus.Status != "running" { t.Fatalf("unexpected VM status: %+v", vmStatus) } ctStatus, err := client.GetContainerStatus(ctx, "node1", 101) if err != nil { t.Fatalf("GetContainerStatus error: %v", err) } if ctStatus.Status != "running" || ctStatus.VMID != 101 { t.Fatalf("unexpected container status: %+v", ctStatus) } resources, err := client.GetClusterResources(ctx, "vm") if err != nil { t.Fatalf("GetClusterResources error: %v", err) } if len(resources) != 1 || resources[0].VMID != 100 { t.Fatalf("unexpected resources: %+v", resources) } ifaces, err := client.GetContainerInterfaces(ctx, "node1", 101) if err != nil { t.Fatalf("GetContainerInterfaces error: %v", err) } if len(ifaces) != 1 || ifaces[0].Name != "eth0" { t.Fatalf("unexpected container interfaces: %+v", ifaces) } }