go-devops/infra/hetzner_test.go
Snider 50ad540241 feat(infra): Phase 2 — API client abstraction, retry logic, rate limiting
Extract shared APIClient from HCloudClient/HRobotClient/CloudNSClient with
configurable retry (exponential backoff + jitter), 429 rate-limit handling
(Retry-After header), and functional options (WithHTTPClient, WithRetry,
WithAuth, WithPrefix). 30 new client_test.go tests covering retry exhaustion,
rate-limit queuing, context cancellation, and integration with all 3 providers.
DigitalOcean: no code existed, removed stale doc references. 66 infra tests
pass, race-clean.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 04:11:08 +00:00

310 lines
9.2 KiB
Go

package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHCloudClient_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 TestHCloudClient_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")
_ = json.NewEncoder(w).Encode(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 TestHCloudClient_Do_Good_ParsesJSON(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 TestHCloudClient_Do_Bad_APIError(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 403")
}
func TestHCloudClient_Do_Bad_APIErrorNoJSON(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 500")
}
func TestHCloudClient_Do_Good_NilResult(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 TestNewHRobotClient_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 TestHRobotClient_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 TestHRobotClient_Get_Bad_HTTPError(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 401")
}
// --- Type serialisation ---
func TestHCloudServer_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
err := json.Unmarshal([]byte(data), &server)
require.NoError(t, err)
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 TestHCloudLoadBalancer_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
err := json.Unmarshal([]byte(data), &lb)
require.NoError(t, err)
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 TestHRobotServer_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
err := json.Unmarshal([]byte(data), &server)
require.NoError(t, err)
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)
}