Merge origin/dev into agent/fix-coderabbit-findings--verify-each-aga

Resolve conflict in hetzner_test.go: keep PR's CodeRabbit fixes
alongside dev's new test functions (load balancer, snapshot, GetServer).

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-24 11:41:09 +00:00
commit 1cecf00148
4 changed files with 381 additions and 1 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
`core/go-infra` provides infrastructure provider API clients (Hetzner Cloud, Hetzner Robot, CloudNS) and a YAML-based infrastructure configuration parser. Zero framework dependencies — stdlib + yaml only.
`core/go-infra` provides infrastructure provider API clients (Hetzner Cloud, Hetzner Robot, CloudNS) and a YAML-based infrastructure configuration parser. Dependencies: `go-log` (error handling), `go-io` (file I/O), `yaml.v3`, and `testify` (tests).
## Build & Test
@ -35,6 +35,8 @@ These are subcommands for the parent `core` CLI, registered via `cli.RegisterCom
## Coding Standards
- UK English in comments and strings
- **Error handling**: Use `coreerr.E()` from `go-log`, never `fmt.Errorf` or `errors.New`
- **File I/O**: Use `coreio.Local.Read()` from `go-io`, never `os.ReadFile`
- Tests use `testify` (`assert` + `require`)
- Test naming: `TestType_Method_Good`, `TestType_Method_Bad`, `TestType_Method_Ugly` suffixes (Good = happy path, Bad = expected errors, Ugly = edge cases)
- Tests use `httptest.NewServer` for HTTP mocking — no mock libraries

View file

@ -517,6 +517,172 @@ func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) {
assert.Empty(t, records)
}
// --- UpdateRecord round-trip ---
func TestCloudNSClient_UpdateRecord_Good_ViaDoRaw(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, "example.com", r.URL.Query().Get("domain-name"))
assert.Equal(t, "42", r.URL.Query().Get("record-id"))
assert.Equal(t, "www", r.URL.Query().Get("host"))
assert.Equal(t, "A", r.URL.Query().Get("record-type"))
assert.Equal(t, "5.6.7.8", r.URL.Query().Get("record"))
assert.Equal(t, "3600", r.URL.Query().Get("ttl"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was updated successfully."}`))
}))
defer ts.Close()
client := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
err := client.UpdateRecord(context.Background(), "example.com", "42", "www", "A", "5.6.7.8", 3600)
require.NoError(t, err)
}
func TestCloudNSClient_UpdateRecord_Bad_FailedStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Record not found."}`))
}))
defer ts.Close()
client := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
err := client.UpdateRecord(context.Background(), "example.com", "999", "www", "A", "5.6.7.8", 3600)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Record not found")
}
// --- EnsureRecord round-trip ---
func TestCloudNSClient_EnsureRecord_Good_AlreadyCorrect(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"1":{"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1}}`))
}))
defer ts.Close()
client := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
changed, err := client.EnsureRecord(context.Background(), "example.com", "www", "A", "1.2.3.4", 3600)
require.NoError(t, err)
assert.False(t, changed, "should not change when record already correct")
}
func TestCloudNSClient_EnsureRecord_Good_NeedsUpdate(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
// ListRecords — returns existing record with old value
_, _ = w.Write([]byte(`{"1":{"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1}}`))
} else {
// UpdateRecord
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "5.6.7.8", r.URL.Query().Get("record"))
_, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was updated successfully."}`))
}
}))
defer ts.Close()
client := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
changed, err := client.EnsureRecord(context.Background(), "example.com", "www", "A", "5.6.7.8", 3600)
require.NoError(t, err)
assert.True(t, changed, "should change when record needs update")
}
func TestCloudNSClient_EnsureRecord_Good_NeedsCreate(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
// ListRecords — no matching record
_, _ = w.Write([]byte(`{"1":{"id":"1","type":"A","host":"other","record":"1.2.3.4","ttl":"3600","status":1}}`))
} else {
// CreateRecord
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "www", r.URL.Query().Get("host"))
_, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was created successfully.","data":{"id":99}}`))
}
}))
defer ts.Close()
client := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
changed, err := client.EnsureRecord(context.Background(), "example.com", "www", "A", "1.2.3.4", 3600)
require.NoError(t, err)
assert.True(t, changed, "should change when record needs to be created")
}
// --- ClearACMEChallenge round-trip ---
func TestCloudNSClient_ClearACMEChallenge_Good_ViaDoRaw(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
// ListRecords — returns ACME challenge records
_, _ = w.Write([]byte(`{
"1":{"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1},
"2":{"id":"2","type":"TXT","host":"_acme-challenge","record":"token1","ttl":"60","status":1}
}`))
} else {
// DeleteRecord
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "2", r.URL.Query().Get("record-id"))
_, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was deleted successfully."}`))
}
}))
defer ts.Close()
client := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
err := client.ClearACMEChallenge(context.Background(), "example.com")
require.NoError(t, err)
assert.GreaterOrEqual(t, callCount, 2, "should have called list + delete")
}
func TestCloudNSClient_DoRaw_Good_AuthQueryParams(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "49500", r.URL.Query().Get("auth-id"))

View file

@ -79,6 +79,55 @@ func TestLoad_Ugly(t *testing.T) {
}
}
func TestConfig_HostsByRole_Good(t *testing.T) {
cfg := &Config{
Hosts: map[string]*Host{
"de": {FQDN: "de.example.com", Role: "app"},
"de2": {FQDN: "de2.example.com", Role: "app"},
"noc": {FQDN: "noc.example.com", Role: "bastion"},
"build": {FQDN: "build.example.com", Role: "builder"},
},
}
apps := cfg.HostsByRole("app")
if len(apps) != 2 {
t.Errorf("HostsByRole(app) = %d, want 2", len(apps))
}
if _, ok := apps["de"]; !ok {
t.Error("expected de in app hosts")
}
if _, ok := apps["de2"]; !ok {
t.Error("expected de2 in app hosts")
}
bastions := cfg.HostsByRole("bastion")
if len(bastions) != 1 {
t.Errorf("HostsByRole(bastion) = %d, want 1", len(bastions))
}
empty := cfg.HostsByRole("nonexistent")
if len(empty) != 0 {
t.Errorf("HostsByRole(nonexistent) = %d, want 0", len(empty))
}
}
func TestConfig_AppServers_Good(t *testing.T) {
cfg := &Config{
Hosts: map[string]*Host{
"de": {FQDN: "de.example.com", Role: "app"},
"noc": {FQDN: "noc.example.com", Role: "bastion"},
},
}
apps := cfg.AppServers()
if len(apps) != 1 {
t.Errorf("AppServers() = %d, want 1", len(apps))
}
if _, ok := apps["de"]; !ok {
t.Error("expected de in AppServers()")
}
}
func TestExpandPath(t *testing.T) {
home, _ := os.UserHomeDir()

View file

@ -221,6 +221,169 @@ func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) {
assert.Contains(t, err.Error(), "hrobot API: HTTP 401")
}
func TestHCloudClient_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 TestHCloudClient_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 TestHCloudClient_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
err := json.NewDecoder(r.Body).Decode(&body)
require.NoError(t, err)
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 TestHCloudClient_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 TestHCloudClient_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
err := json.NewDecoder(r.Body).Decode(&body)
require.NoError(t, err)
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 TestHRobotClient_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 TestHCloudServer_JSON_Good(t *testing.T) {