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>
545 lines
15 KiB
Go
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)
|
|
}
|