go-devops/infra/cloudns_test.go
Snider 6e346cb2fd test(devops): Phase 0 test coverage and hardening
- Fix go vet warnings across 4 files: update stale API calls in
  container/linuxkit_test.go, container/state_test.go, and
  devops/devops_test.go (removed io.Local arg from NewState/LoadState),
  rewrite container/templates_test.go for package-level function API
- Add ansible/parser_test.go: 17 tests covering ParsePlaybook,
  ParseInventory, ParseTasks, GetHosts, GetHostVars, isModule,
  NormalizeModule (plays, vars, handlers, blocks, loops, roles, FQCN)
- Add ansible/types_test.go: RoleRef/Task UnmarshalYAML, Inventory
  structure, Facts, TaskResult, KnownModules validation
- Add ansible/executor_test.go: executor logic (getHosts, matchesTags,
  evaluateWhen, templateString, applyFilter, resolveLoop, templateArgs,
  handleNotify, normalizeConditions, helper functions)
- Add infra/hetzner_test.go: HCloudClient/HRobotClient construction,
  do() round-trip via httptest, API error handling, JSON serialisation
  for HCloudServer, HCloudLoadBalancer, HRobotServer
- Add infra/cloudns_test.go: doRaw() round-trip via httptest, zone/record
  JSON parsing, CRUD response validation, ACME challenge logic,
  auth param verification, error handling
- Fix go.mod replace directive path (../go -> ../core)
- All tests pass, go vet clean, go test -race clean

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:36:03 +00:00

625 lines
17 KiB
Go

package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- Constructor ---
func TestNewCloudNSClient_Good(t *testing.T) {
c := NewCloudNSClient("12345", "secret")
assert.NotNil(t, c)
assert.Equal(t, "12345", c.authID)
assert.Equal(t, "secret", c.password)
assert.NotNil(t, c.client)
}
// --- authParams ---
func TestCloudNSClient_AuthParams_Good(t *testing.T) {
c := NewCloudNSClient("49500", "hunter2")
params := c.authParams()
assert.Equal(t, "49500", params.Get("auth-id"))
assert.Equal(t, "hunter2", params.Get("auth-password"))
}
// --- doRaw ---
func TestCloudNSClient_DoRaw_Good_ReturnsBody(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":"Success"}`))
}))
defer ts.Close()
client := &CloudNSClient{
authID: "test",
password: "test",
client: ts.Client(),
}
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/test.json", nil)
require.NoError(t, err)
data, err := client.doRaw(req)
require.NoError(t, err)
assert.Contains(t, string(data), "Success")
}
func TestCloudNSClient_DoRaw_Bad_HTTPError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Invalid auth"}`))
}))
defer ts.Close()
client := &CloudNSClient{
authID: "bad",
password: "creds",
client: ts.Client(),
}
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/test.json", nil)
require.NoError(t, err)
_, err = client.doRaw(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cloudns API 403")
}
func TestCloudNSClient_DoRaw_Bad_ServerError(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 := &CloudNSClient{
authID: "test",
password: "test",
client: ts.Client(),
}
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/test", nil)
require.NoError(t, err)
_, err = client.doRaw(req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cloudns API 500")
}
// --- Zone JSON parsing ---
func TestCloudNSZone_JSON_Good(t *testing.T) {
data := `[
{"name": "example.com", "type": "master", "zone": "domain", "status": "1"},
{"name": "test.io", "type": "master", "zone": "domain", "status": "1"}
]`
var zones []CloudNSZone
err := json.Unmarshal([]byte(data), &zones)
require.NoError(t, err)
require.Len(t, zones, 2)
assert.Equal(t, "example.com", zones[0].Name)
assert.Equal(t, "master", zones[0].Type)
assert.Equal(t, "test.io", zones[1].Name)
}
func TestCloudNSZone_JSON_Good_EmptyResponse(t *testing.T) {
// CloudNS returns {} for no zones, not []
data := `{}`
var zones []CloudNSZone
err := json.Unmarshal([]byte(data), &zones)
// Should fail to parse as slice — this is the edge case ListZones handles
assert.Error(t, err)
}
// --- Record JSON parsing ---
func TestCloudNSRecord_JSON_Good(t *testing.T) {
data := `{
"12345": {
"id": "12345",
"type": "A",
"host": "www",
"record": "1.2.3.4",
"ttl": "3600",
"status": 1
},
"12346": {
"id": "12346",
"type": "MX",
"host": "",
"record": "mail.example.com",
"ttl": "3600",
"priority": "10",
"status": 1
}
}`
var records map[string]CloudNSRecord
err := json.Unmarshal([]byte(data), &records)
require.NoError(t, err)
require.Len(t, records, 2)
aRecord := records["12345"]
assert.Equal(t, "12345", aRecord.ID)
assert.Equal(t, "A", aRecord.Type)
assert.Equal(t, "www", aRecord.Host)
assert.Equal(t, "1.2.3.4", aRecord.Record)
assert.Equal(t, "3600", aRecord.TTL)
assert.Equal(t, 1, aRecord.Status)
mxRecord := records["12346"]
assert.Equal(t, "MX", mxRecord.Type)
assert.Equal(t, "mail.example.com", mxRecord.Record)
assert.Equal(t, "10", mxRecord.Priority)
}
func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) {
data := `{
"99": {
"id": "99",
"type": "TXT",
"host": "_acme-challenge",
"record": "abc123def456",
"ttl": "60",
"status": 1
}
}`
var records map[string]CloudNSRecord
err := json.Unmarshal([]byte(data), &records)
require.NoError(t, err)
require.Len(t, records, 1)
txt := records["99"]
assert.Equal(t, "TXT", txt.Type)
assert.Equal(t, "_acme-challenge", txt.Host)
assert.Equal(t, "abc123def456", txt.Record)
assert.Equal(t, "60", txt.TTL)
}
// --- CreateRecord response parsing ---
func TestCloudNSClient_CreateRecord_Good_ResponseParsing(t *testing.T) {
// Verify the response shape CreateRecord expects
data := `{"status":"Success","statusDescription":"The record was created successfully.","data":{"id":54321}}`
var result struct {
Status string `json:"status"`
StatusDescription string `json:"statusDescription"`
Data struct {
ID int `json:"id"`
} `json:"data"`
}
err := json.Unmarshal([]byte(data), &result)
require.NoError(t, err)
assert.Equal(t, "Success", result.Status)
assert.Equal(t, 54321, result.Data.ID)
}
func TestCloudNSClient_CreateRecord_Bad_FailedStatus(t *testing.T) {
// Verify non-Success status produces an error message
data := `{"status":"Failed","statusDescription":"Record already exists."}`
var result struct {
Status string `json:"status"`
StatusDescription string `json:"statusDescription"`
}
err := json.Unmarshal([]byte(data), &result)
require.NoError(t, err)
assert.Equal(t, "Failed", result.Status)
assert.Equal(t, "Record already exists.", result.StatusDescription)
}
// --- UpdateRecord/DeleteRecord response parsing ---
func TestCloudNSClient_UpdateDelete_Good_ResponseParsing(t *testing.T) {
data := `{"status":"Success","statusDescription":"The record was updated successfully."}`
var result struct {
Status string `json:"status"`
StatusDescription string `json:"statusDescription"`
}
err := json.Unmarshal([]byte(data), &result)
require.NoError(t, err)
assert.Equal(t, "Success", result.Status)
}
// --- Full round-trip tests via doRaw ---
func TestCloudNSClient_ListZones_Good_ViaDoRaw(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify auth params are passed
assert.NotEmpty(t, r.URL.Query().Get("auth-id"))
assert.NotEmpty(t, r.URL.Query().Get("auth-password"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"example.com","type":"master","zone":"domain","status":"1"}]`))
}))
defer ts.Close()
client := &CloudNSClient{
authID: "12345",
password: "secret",
client: ts.Client(),
}
// Build a request similar to what get() would build, but pointing at test server
ctx := context.Background()
params := client.authParams()
params.Set("page", "1")
params.Set("rows-per-page", "100")
params.Set("search", "")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/list-zones.json?"+params.Encode(), nil)
require.NoError(t, err)
data, err := client.doRaw(req)
require.NoError(t, err)
var zones []CloudNSZone
err = json.Unmarshal(data, &zones)
require.NoError(t, err)
require.Len(t, zones, 1)
assert.Equal(t, "example.com", zones[0].Name)
}
func TestCloudNSClient_ListRecords_Good_ViaDoRaw(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
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},
"2": {"id":"2","type":"CNAME","host":"blog","record":"www.example.com","ttl":"3600","status":1}
}`))
}))
defer ts.Close()
client := &CloudNSClient{
authID: "12345",
password: "secret",
client: ts.Client(),
}
ctx := context.Background()
params := client.authParams()
params.Set("domain-name", "example.com")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/records.json?"+params.Encode(), nil)
require.NoError(t, err)
data, err := client.doRaw(req)
require.NoError(t, err)
var records map[string]CloudNSRecord
err = json.Unmarshal(data, &records)
require.NoError(t, err)
require.Len(t, records, 2)
assert.Equal(t, "A", records["1"].Type)
assert.Equal(t, "CNAME", records["2"].Type)
}
func TestCloudNSClient_CreateRecord_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, "www", r.URL.Query().Get("host"))
assert.Equal(t, "A", r.URL.Query().Get("record-type"))
assert.Equal(t, "1.2.3.4", 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 created successfully.","data":{"id":99}}`))
}))
defer ts.Close()
client := &CloudNSClient{
authID: "12345",
password: "secret",
client: ts.Client(),
}
ctx := context.Background()
params := client.authParams()
params.Set("domain-name", "example.com")
params.Set("host", "www")
params.Set("record-type", "A")
params.Set("record", "1.2.3.4")
params.Set("ttl", "3600")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/dns/add-record.json", nil)
require.NoError(t, err)
req.URL.RawQuery = params.Encode()
data, err := client.doRaw(req)
require.NoError(t, err)
var result struct {
Status string `json:"status"`
Data struct {
ID int `json:"id"`
} `json:"data"`
}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "Success", result.Status)
assert.Equal(t, 99, result.Data.ID)
}
func TestCloudNSClient_DeleteRecord_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"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was deleted successfully."}`))
}))
defer ts.Close()
client := &CloudNSClient{
authID: "12345",
password: "secret",
client: ts.Client(),
}
ctx := context.Background()
params := client.authParams()
params.Set("domain-name", "example.com")
params.Set("record-id", "42")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/dns/delete-record.json", nil)
require.NoError(t, err)
req.URL.RawQuery = params.Encode()
data, err := client.doRaw(req)
require.NoError(t, err)
var result struct {
Status string `json:"status"`
}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "Success", result.Status)
}
// --- ACME challenge helpers ---
func TestCloudNSClient_SetACMEChallenge_Good_ParamVerification(t *testing.T) {
// SetACMEChallenge delegates to CreateRecord with specific params.
// Verify the delegation shape by checking the expected call.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
assert.Equal(t, "_acme-challenge", r.URL.Query().Get("host"))
assert.Equal(t, "TXT", r.URL.Query().Get("record-type"))
assert.Equal(t, "60", r.URL.Query().Get("ttl"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"Success","statusDescription":"OK","data":{"id":777}}`))
}))
defer ts.Close()
client := &CloudNSClient{
authID: "12345",
password: "secret",
client: ts.Client(),
}
// Build request matching what SetACMEChallenge -> CreateRecord -> post() builds
ctx := context.Background()
params := client.authParams()
params.Set("domain-name", "example.com")
params.Set("host", "_acme-challenge")
params.Set("record-type", "TXT")
params.Set("record", "acme-token-value")
params.Set("ttl", "60")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/dns/add-record.json", nil)
require.NoError(t, err)
req.URL.RawQuery = params.Encode()
data, err := client.doRaw(req)
require.NoError(t, err)
var result struct {
Status string `json:"status"`
Data struct {
ID int `json:"id"`
} `json:"data"`
}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "Success", result.Status)
assert.Equal(t, 777, result.Data.ID)
}
func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) {
// ClearACMEChallenge lists records, finds _acme-challenge TXT records, deletes them.
// Test the logic by verifying the record filtering.
records := map[string]CloudNSRecord{
"1": {ID: "1", Type: "A", Host: "www", Record: "1.2.3.4"},
"2": {ID: "2", Type: "TXT", Host: "_acme-challenge", Record: "token1"},
"3": {ID: "3", Type: "TXT", Host: "_dmarc", Record: "v=DMARC1"},
"4": {ID: "4", Type: "TXT", Host: "_acme-challenge", Record: "token2"},
}
// Simulate the filtering logic from ClearACMEChallenge
var toDelete []string
for id, r := range records {
if r.Host == "_acme-challenge" && r.Type == "TXT" {
toDelete = append(toDelete, id)
}
}
assert.Len(t, toDelete, 2)
assert.Contains(t, toDelete, "2")
assert.Contains(t, toDelete, "4")
}
// --- EnsureRecord logic ---
func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) {
// Simulate the check: host matches, type matches, value matches => no change
records := map[string]CloudNSRecord{
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
}
host := "www"
recordType := "A"
value := "1.2.3.4"
var needsUpdate, needsCreate bool
for _, r := range records {
if r.Host == host && r.Type == recordType {
if r.Record == value {
// Already correct — no change needed
needsUpdate = false
needsCreate = false
} else {
needsUpdate = true
}
break
}
}
if !needsUpdate {
// Check if we found any match at all
found := false
for _, r := range records {
if r.Host == host && r.Type == recordType {
found = true
break
}
}
if !found {
needsCreate = true
}
}
assert.False(t, needsUpdate, "should not need update when value matches")
assert.False(t, needsCreate, "should not need create when record exists")
}
func TestEnsureRecord_Good_Logic_NeedsUpdate(t *testing.T) {
records := map[string]CloudNSRecord{
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
}
host := "www"
recordType := "A"
value := "5.6.7.8" // Different value
var needsUpdate bool
for _, r := range records {
if r.Host == host && r.Type == recordType {
if r.Record != value {
needsUpdate = true
}
break
}
}
assert.True(t, needsUpdate, "should need update when value differs")
}
func TestEnsureRecord_Good_Logic_NeedsCreate(t *testing.T) {
records := map[string]CloudNSRecord{
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
}
host := "api" // Does not exist
recordType := "A"
found := false
for _, r := range records {
if r.Host == host && r.Type == recordType {
found = true
break
}
}
assert.False(t, found, "should not find record for non-existent host")
}
// --- Edge cases ---
func TestCloudNSClient_DoRaw_Good_EmptyBody(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
// Empty body
}))
defer ts.Close()
client := &CloudNSClient{
authID: "test",
password: "test",
client: ts.Client(),
}
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/test", nil)
require.NoError(t, err)
data, err := client.doRaw(req)
require.NoError(t, err)
assert.Empty(t, data)
}
func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) {
// An empty record set is a valid empty map
data := `{}`
var records map[string]CloudNSRecord
err := json.Unmarshal([]byte(data), &records)
require.NoError(t, err)
assert.Empty(t, records)
}
func TestCloudNSClient_DoRaw_Good_AuthQueryParams(t *testing.T) {
// Verify that auth params make it to the server in the query string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "49500", r.URL.Query().Get("auth-id"))
assert.Equal(t, "supersecret", r.URL.Query().Get("auth-password"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer ts.Close()
client := &CloudNSClient{
authID: "49500",
password: "supersecret",
client: ts.Client(),
}
ctx := context.Background()
params := client.authParams()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/dns/test.json?"+params.Encode(), nil)
require.NoError(t, err)
_, err = client.doRaw(req)
require.NoError(t, err)
}