go-devops/infra/cloudns_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

545 lines
15 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.api)
}
// --- 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 := NewCloudNSClient("test", "test")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
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 := NewCloudNSClient("bad", "creds")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
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 := NewCloudNSClient("test", "test")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
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) {
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) {
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) {
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 := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
zones, err := client.ListZones(context.Background())
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 := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
records, err := client.ListRecords(context.Background(), "example.com")
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 := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
id, err := client.CreateRecord(context.Background(), "example.com", "www", "A", "1.2.3.4", 3600)
require.NoError(t, err)
assert.Equal(t, "99", 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 := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
err := client.DeleteRecord(context.Background(), "example.com", "42")
require.NoError(t, err)
}
// --- ACME challenge helpers ---
func TestCloudNSClient_SetACMEChallenge_Good_ParamVerification(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"))
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 := NewCloudNSClient("12345", "secret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
id, err := client.SetACMEChallenge(context.Background(), "example.com", "acme-token-value")
require.NoError(t, err)
assert.Equal(t, "777", id)
}
func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) {
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"},
}
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) {
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 {
needsUpdate = false
needsCreate = false
} else {
needsUpdate = true
}
break
}
}
if !needsUpdate {
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"
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"
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)
}))
defer ts.Close()
client := NewCloudNSClient("test", "test")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
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) {
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) {
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 := NewCloudNSClient("49500", "supersecret")
client.baseURL = ts.URL
client.api = NewAPIClient(
WithHTTPClient(ts.Client()),
WithPrefix("cloudns API"),
WithRetry(RetryConfig{}),
)
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)
}