package infra import ( "context" "io" "net/http" "net/http/httptest" "testing" core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHetzner_NewHCloudClient_Good(t *testing.T) { c := NewHCloudClient("my-token") assert.NotNil(t, c) assert.Equal(t, "my-token", c.token) assert.NotNil(t, c.api) } func TestHetzner_HCloudClient_ListServers_Good(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) assert.Equal(t, http.MethodGet, r.Method) resp := map[string]any{ "servers": []map[string]any{ { "id": 1, "name": "de1", "status": "running", "public_net": map[string]any{"ipv4": map[string]any{"ip": "1.2.3.4"}}, "server_type": map[string]any{"name": "cx22", "cores": 2, "memory": 4.0, "disk": 40}, "datacenter": map[string]any{"name": "fsn1-dc14"}, }, { "id": 2, "name": "de2", "status": "running", "public_net": map[string]any{"ipv4": map[string]any{"ip": "5.6.7.8"}}, "server_type": map[string]any{"name": "cx32", "cores": 4, "memory": 8.0, "disk": 80}, "datacenter": map[string]any{"name": "nbg1-dc3"}, }, }, } w.Header().Set("Content-Type", "application/json") writeCoreJSON(t, w, resp) }) ts := httptest.NewServer(mux) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithAuth(func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+client.token) }), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), // no retries in tests ) servers, err := client.ListServers(context.Background()) require.NoError(t, err) require.Len(t, servers, 2) assert.Equal(t, "de1", servers[0].Name) assert.Equal(t, "de2", servers[1].Name) } func TestHetzner_HCloudClient_Do_ParsesJSON_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"servers":[{"id":1,"name":"test","status":"running"}]}`)) })) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithAuth(func(req *http.Request) { req.Header.Set("Authorization", "Bearer test-token") }), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) var result struct { Servers []HCloudServer `json:"servers"` } err := client.get(context.Background(), "/servers", &result) require.NoError(t, err) require.Len(t, result.Servers, 1) assert.Equal(t, 1, result.Servers[0].ID) assert.Equal(t, "test", result.Servers[0].Name) assert.Equal(t, "running", result.Servers[0].Status) } func TestHetzner_HCloudClient_Do_APIError_Bad(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":{"code":"forbidden","message":"insufficient permissions"}}`)) })) defer ts.Close() client := NewHCloudClient("bad-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithAuth(func(req *http.Request) { req.Header.Set("Authorization", "Bearer bad-token") }), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) var result struct{} err := client.get(context.Background(), "/servers", &result) assert.Error(t, err) assert.Contains(t, err.Error(), "hcloud API: HTTP 403") } func TestHetzner_HCloudClient_Do_APIErrorNoJSON_Bad(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`Internal Server Error`)) })) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) err := client.get(context.Background(), "/servers", nil) assert.Error(t, err) assert.Contains(t, err.Error(), "hcloud API: HTTP 500") } func TestHetzner_HCloudClient_Do_NilResult_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) err := client.delete(context.Background(), "/servers/1") assert.NoError(t, err) } // --- Hetzner Robot API --- func TestHetzner_NewHRobotClient_Good(t *testing.T) { c := NewHRobotClient("user", "pass") assert.NotNil(t, c) assert.Equal(t, "user", c.user) assert.Equal(t, "pass", c.password) assert.NotNil(t, c.api) } func TestHetzner_HRobotClient_ListServers_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() assert.True(t, ok) assert.Equal(t, "testuser", user) assert.Equal(t, "testpass", pass) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"server":{"server_ip":"1.2.3.4","server_name":"test","product":"EX44","dc":"FSN1","status":"ready","cancelled":false}}]`)) })) defer ts.Close() client := NewHRobotClient("testuser", "testpass") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithAuth(func(req *http.Request) { req.SetBasicAuth("testuser", "testpass") }), WithPrefix("hrobot API"), WithRetry(RetryConfig{}), ) servers, err := client.ListServers(context.Background()) require.NoError(t, err) require.Len(t, servers, 1) assert.Equal(t, "1.2.3.4", servers[0].ServerIP) assert.Equal(t, "test", servers[0].ServerName) assert.Equal(t, "EX44", servers[0].Product) } func TestHetzner_HRobotClient_Get_HTTPError_Bad(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":{"status":401,"code":"UNAUTHORIZED","message":"Invalid credentials"}}`)) })) defer ts.Close() client := NewHRobotClient("bad", "creds") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithAuth(func(req *http.Request) { req.SetBasicAuth("bad", "creds") }), WithPrefix("hrobot API"), WithRetry(RetryConfig{}), ) err := client.get(context.Background(), "/server", nil) assert.Error(t, err) assert.Contains(t, err.Error(), "hrobot API: HTTP 401") } func TestHetzner_HCloudClient_ListLoadBalancers_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodGet, r.Method) assert.Equal(t, "/load_balancers", r.URL.Path) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"load_balancers":[{"id":789,"name":"hermes","public_net":{"enabled":true,"ipv4":{"ip":"5.6.7.8"}}}]}`)) })) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) lbs, err := client.ListLoadBalancers(context.Background()) require.NoError(t, err) require.Len(t, lbs, 1) assert.Equal(t, "hermes", lbs[0].Name) assert.Equal(t, 789, lbs[0].ID) } func TestHetzner_HCloudClient_GetLoadBalancer_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/load_balancers/789", r.URL.Path) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"load_balancer":{"id":789,"name":"hermes","public_net":{"enabled":true,"ipv4":{"ip":"5.6.7.8"}}}}`)) })) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) lb, err := client.GetLoadBalancer(context.Background(), 789) require.NoError(t, err) assert.Equal(t, "hermes", lb.Name) } func TestHetzner_HCloudClient_CreateLoadBalancer_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, "/load_balancers", r.URL.Path) var body HCloudLBCreateRequest decodeCoreJSONBody(t, r, &body) assert.Equal(t, "hermes", body.Name) assert.Equal(t, "lb11", body.LoadBalancerType) assert.Equal(t, "round_robin", body.Algorithm.Type) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"load_balancer":{"id":789,"name":"hermes","public_net":{"enabled":true,"ipv4":{"ip":"5.6.7.8"}}}}`)) })) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithAuth(func(req *http.Request) { req.Header.Set("Authorization", "Bearer test-token") }), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) lb, err := client.CreateLoadBalancer(context.Background(), HCloudLBCreateRequest{ Name: "hermes", LoadBalancerType: "lb11", Location: "fsn1", Algorithm: HCloudLBAlgorithm{Type: "round_robin"}, }) require.NoError(t, err) assert.Equal(t, "hermes", lb.Name) assert.Equal(t, 789, lb.ID) } func TestHetzner_HCloudClient_DeleteLoadBalancer_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodDelete, r.Method) assert.Equal(t, "/load_balancers/789", r.URL.Path) w.WriteHeader(http.StatusNoContent) })) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) err := client.DeleteLoadBalancer(context.Background(), 789) assert.NoError(t, err) } func TestHetzner_HCloudClient_CreateSnapshot_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, "/servers/123/actions/create_image", r.URL.Path) var body map[string]string decodeCoreJSONBody(t, r, &body) assert.Equal(t, "daily backup", body["description"]) assert.Equal(t, "snapshot", body["type"]) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"image":{"id":456}}`)) })) defer ts.Close() client := NewHCloudClient("test-token") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithAuth(func(req *http.Request) { req.Header.Set("Authorization", "Bearer test-token") }), WithPrefix("hcloud API"), WithRetry(RetryConfig{}), ) err := client.CreateSnapshot(context.Background(), 123, "daily backup") assert.NoError(t, err) } func TestHetzner_HRobotClient_GetServer_Good(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/server/1.2.3.4", r.URL.Path) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"server":{"server_ip":"1.2.3.4","server_name":"noc","product":"EX44","dc":"FSN1","status":"ready","cancelled":false}}`)) })) defer ts.Close() client := NewHRobotClient("testuser", "testpass") client.baseURL = ts.URL client.api = NewAPIClient( WithHTTPClient(ts.Client()), WithAuth(func(req *http.Request) { req.SetBasicAuth("testuser", "testpass") }), WithPrefix("hrobot API"), WithRetry(RetryConfig{}), ) server, err := client.GetServer(context.Background(), "1.2.3.4") require.NoError(t, err) assert.Equal(t, "noc", server.ServerName) assert.Equal(t, "EX44", server.Product) } // --- Type serialisation --- func TestHetzner_HCloudServer_JSON_Good(t *testing.T) { data := `{ "id": 123, "name": "web-1", "status": "running", "public_net": {"ipv4": {"ip": "10.0.0.1"}}, "private_net": [{"ip": "10.0.1.1", "network": 456}], "server_type": {"name": "cx22", "cores": 2, "memory": 4.0, "disk": 40}, "datacenter": {"name": "fsn1-dc14"}, "labels": {"env": "prod"} }` var server HCloudServer requireHetznerJSON(t, data, &server) assert.Equal(t, 123, server.ID) assert.Equal(t, "web-1", server.Name) assert.Equal(t, "running", server.Status) assert.Equal(t, "10.0.0.1", server.PublicNet.IPv4.IP) assert.Len(t, server.PrivateNet, 1) assert.Equal(t, "10.0.1.1", server.PrivateNet[0].IP) assert.Equal(t, "cx22", server.ServerType.Name) assert.Equal(t, 2, server.ServerType.Cores) assert.Equal(t, 4.0, server.ServerType.Memory) assert.Equal(t, "fsn1-dc14", server.Datacenter.Name) assert.Equal(t, "prod", server.Labels["env"]) } func TestHetzner_HCloudLoadBalancer_JSON_Good(t *testing.T) { data := `{ "id": 789, "name": "hermes", "public_net": {"enabled": true, "ipv4": {"ip": "5.6.7.8"}}, "algorithm": {"type": "round_robin"}, "services": [ {"protocol": "https", "listen_port": 443, "destination_port": 8080, "proxyprotocol": true} ], "targets": [ {"type": "ip", "ip": {"ip": "10.0.0.1"}, "health_status": [{"listen_port": 443, "status": "healthy"}]} ], "labels": {"role": "lb"} }` var lb HCloudLoadBalancer requireHetznerJSON(t, data, &lb) assert.Equal(t, 789, lb.ID) assert.Equal(t, "hermes", lb.Name) assert.True(t, lb.PublicNet.Enabled) assert.Equal(t, "5.6.7.8", lb.PublicNet.IPv4.IP) assert.Equal(t, "round_robin", lb.Algorithm.Type) require.Len(t, lb.Services, 1) assert.Equal(t, 443, lb.Services[0].ListenPort) assert.True(t, lb.Services[0].Proxyprotocol) require.Len(t, lb.Targets, 1) assert.Equal(t, "ip", lb.Targets[0].Type) assert.Equal(t, "10.0.0.1", lb.Targets[0].IP.IP) assert.Equal(t, "healthy", lb.Targets[0].HealthStatus[0].Status) } func TestHetzner_HRobotServer_JSON_Good(t *testing.T) { data := `{ "server_ip": "1.2.3.4", "server_name": "noc", "product": "EX44", "dc": "FSN1-DC14", "status": "ready", "cancelled": false, "paid_until": "2026-03-01" }` var server HRobotServer requireHetznerJSON(t, data, &server) assert.Equal(t, "1.2.3.4", server.ServerIP) assert.Equal(t, "noc", server.ServerName) assert.Equal(t, "EX44", server.Product) assert.Equal(t, "FSN1-DC14", server.Datacenter) assert.Equal(t, "ready", server.Status) assert.False(t, server.Cancelled) assert.Equal(t, "2026-03-01", server.PaidUntil) } func requireHetznerJSON(t *testing.T, data string, target any) { t.Helper() r := core.JSONUnmarshal([]byte(data), target) require.True(t, r.OK) } func writeCoreJSON(t *testing.T, w http.ResponseWriter, value any) { t.Helper() r := core.JSONMarshal(value) require.True(t, r.OK) _, err := w.Write(r.Value.([]byte)) require.NoError(t, err) } func decodeCoreJSONBody(t *testing.T, r *http.Request, target any) { t.Helper() body, err := io.ReadAll(r.Body) require.NoError(t, err) result := core.JSONUnmarshal(body, target) require.True(t, result.OK) }