godaddy: add tests on the client (#1521)

This commit is contained in:
Ludovic Fernandez 2021-11-01 22:02:31 +01:00 committed by GitHub
parent 60ae6e6dc9
commit 15f3a45342
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 251 additions and 29 deletions

View file

@ -10,13 +10,10 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/godaddy/internal"
) )
const ( const minTTL = 600
// defaultBaseURL represents the API endpoint to call.
defaultBaseURL = "https://api.godaddy.com"
minTTL = 600
)
// Environment variables names. // Environment variables names.
const ( const (
@ -56,6 +53,7 @@ func NewDefaultConfig() *Config {
// DNSProvider implements the challenge.Provider interface. // DNSProvider implements the challenge.Provider interface.
type DNSProvider struct { type DNSProvider struct {
config *Config config *Config
client *internal.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for godaddy. // NewDNSProvider returns a DNSProvider instance configured for godaddy.
@ -88,7 +86,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
return nil, fmt.Errorf("godaddy: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) return nil, fmt.Errorf("godaddy: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
} }
return &DNSProvider{config: config}, nil client := internal.NewClient(config.APIKey, config.APISecret)
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
return &DNSProvider{config: config, client: client}, nil
} }
// Timeout returns the timeout and interval to use when checking for DNS // Timeout returns the timeout and interval to use when checking for DNS
@ -108,19 +112,19 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
recordName := extractRecordName(fqdn, domainZone) recordName := extractRecordName(fqdn, domainZone)
records, err := d.getRecords(domainZone, "TXT", recordName) records, err := d.client.GetRecords(domainZone, "TXT", recordName)
if err != nil { if err != nil {
return fmt.Errorf("godaddy: failed to get TXT records: %w", err) return fmt.Errorf("godaddy: failed to get TXT records: %w", err)
} }
var newRecords []DNSRecord var newRecords []internal.DNSRecord
for _, record := range records { for _, record := range records {
if record.Data != "" { if record.Data != "" {
newRecords = append(newRecords, record) newRecords = append(newRecords, record)
} }
} }
record := DNSRecord{ record := internal.DNSRecord{
Type: "TXT", Type: "TXT",
Name: recordName, Name: recordName,
Data: value, Data: value,
@ -128,7 +132,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
} }
newRecords = append(newRecords, record) newRecords = append(newRecords, record)
err = d.updateTxtRecords(newRecords, domainZone, recordName) err = d.client.UpdateTxtRecords(newRecords, domainZone, recordName)
if err != nil { if err != nil {
return fmt.Errorf("godaddy: failed to add TXT record: %w", err) return fmt.Errorf("godaddy: failed to add TXT record: %w", err)
} }
@ -147,7 +151,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
recordName := extractRecordName(fqdn, domainZone) recordName := extractRecordName(fqdn, domainZone)
records, err := d.getRecords(domainZone, "TXT", recordName) records, err := d.client.GetRecords(domainZone, "TXT", recordName)
if err != nil { if err != nil {
return fmt.Errorf("godaddy: failed to get TXT records: %w", err) return fmt.Errorf("godaddy: failed to get TXT records: %w", err)
} }
@ -156,12 +160,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil return nil
} }
allTxtRecords, err := d.getRecords(domainZone, "TXT", "") allTxtRecords, err := d.client.GetRecords(domainZone, "TXT", "")
if err != nil { if err != nil {
return fmt.Errorf("godaddy: failed to get all TXT records: %w", err) return fmt.Errorf("godaddy: failed to get all TXT records: %w", err)
} }
var recordsKeep []DNSRecord var recordsKeep []internal.DNSRecord
for _, record := range allTxtRecords { for _, record := range allTxtRecords {
if record.Data != value && record.Data != "" { if record.Data != value && record.Data != "" {
recordsKeep = append(recordsKeep, record) recordsKeep = append(recordsKeep, record)
@ -170,11 +174,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
// GoDaddy API don't provide a way to delete a record, an "empty" record must be added. // GoDaddy API don't provide a way to delete a record, an "empty" record must be added.
if len(recordsKeep) == 0 { if len(recordsKeep) == 0 {
emptyRecord := DNSRecord{Name: "empty", Data: ""} emptyRecord := internal.DNSRecord{Name: "empty", Data: ""}
recordsKeep = append(recordsKeep, emptyRecord) recordsKeep = append(recordsKeep, emptyRecord)
} }
err = d.updateTxtRecords(recordsKeep, domainZone, "") err = d.client.UpdateTxtRecords(recordsKeep, domainZone, "")
if err != nil { if err != nil {
return fmt.Errorf("godaddy: failed to remove TXT record: %w", err) return fmt.Errorf("godaddy: failed to remove TXT record: %w", err)
} }

View file

@ -1,4 +1,4 @@
package godaddy package internal
import ( import (
"bytes" "bytes"
@ -6,19 +6,33 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"path" "path"
"time"
) )
// DNSRecord a DNS record. // DefaultBaseURL represents the API endpoint to call.
type DNSRecord struct { const DefaultBaseURL = "https://api.godaddy.com"
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"` type Client struct {
Data string `json:"data"` HTTPClient *http.Client
Priority int `json:"priority,omitempty"` baseURL *url.URL
TTL int `json:"ttl,omitempty"` apiKey string
apiSecret string
} }
func (d *DNSProvider) getRecords(domainZone, rType, recordName string) ([]DNSRecord, error) { func NewClient(apiKey string, apiSecret string) *Client {
baseURL, _ := url.Parse(DefaultBaseURL)
return &Client{
HTTPClient: &http.Client{Timeout: 5 * time.Second},
baseURL: baseURL,
apiKey: apiKey,
apiSecret: apiSecret,
}
}
func (d *Client) GetRecords(domainZone, rType, recordName string) ([]DNSRecord, error) {
resource := path.Clean(fmt.Sprintf("/v1/domains/%s/records/%s/%s", domainZone, rType, recordName)) resource := path.Clean(fmt.Sprintf("/v1/domains/%s/records/%s/%s", domainZone, rType, recordName))
resp, err := d.makeRequest(http.MethodGet, resource, nil) resp, err := d.makeRequest(http.MethodGet, resource, nil)
@ -43,7 +57,7 @@ func (d *DNSProvider) getRecords(domainZone, rType, recordName string) ([]DNSRec
return records, nil return records, nil
} }
func (d *DNSProvider) updateTxtRecords(records []DNSRecord, domainZone, recordName string) error { func (d *Client) UpdateTxtRecords(records []DNSRecord, domainZone, recordName string) error {
body, err := json.Marshal(records) body, err := json.Marshal(records)
if err != nil { if err != nil {
return err return err
@ -67,15 +81,20 @@ func (d *DNSProvider) updateTxtRecords(records []DNSRecord, domainZone, recordNa
return nil return nil
} }
func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (*http.Response, error) { func (d *Client) makeRequest(method, uri string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, fmt.Sprintf("%s%s", defaultBaseURL, uri), body) endpoint, err := d.baseURL.Parse(path.Join(d.baseURL.Path, uri))
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, endpoint.String(), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", d.config.APIKey, d.config.APISecret)) req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", d.apiKey, d.apiSecret))
return d.config.HTTPClient.Do(req) return d.HTTPClient.Do(req)
} }

View file

@ -0,0 +1,142 @@
package internal
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTest(t *testing.T) (*http.ServeMux, *Client) {
t.Helper()
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := NewClient("key", "secret")
client.HTTPClient = server.Client()
client.baseURL, _ = url.Parse(server.URL)
return mux, client
}
func TestClient_GetRecords(t *testing.T) {
mux, client := setupTest(t)
mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusOK, "getrecords.json"))
records, err := client.GetRecords("example.com", "TXT", "")
require.NoError(t, err)
expected := []DNSRecord{
{Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600},
{Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600},
{Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600},
}
assert.Equal(t, expected, records)
}
func TestClient_GetRecords_errors(t *testing.T) {
mux, client := setupTest(t)
mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusUnprocessableEntity, "errors.json"))
records, err := client.GetRecords("example.com", "TXT", "")
require.Error(t, err)
assert.Nil(t, records)
}
func TestClient_UpdateTxtRecords(t *testing.T) {
mux, client := setupTest(t)
mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", func(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPut {
http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
return
}
auth := req.Header.Get("Authorization")
if auth != "sso-key key:secret" {
http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized)
return
}
})
records := []DNSRecord{
{Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600},
{Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600},
{Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600},
}
err := client.UpdateTxtRecords(records, "example.com", "lego")
require.NoError(t, err)
}
func TestClient_UpdateTxtRecords_errors(t *testing.T) {
mux, client := setupTest(t)
mux.HandleFunc("/v1/domains/example.com/records/TXT/lego",
testHandler(http.MethodPut, http.StatusUnprocessableEntity, "errors.json"))
records := []DNSRecord{
{Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600},
{Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600},
{Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600},
{Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600},
}
err := client.UpdateTxtRecords(records, "example.com", "lego")
require.Error(t, err)
}
func testHandler(method string, statusCode int, filename string) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
if req.Method != method {
http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed)
return
}
auth := req.Header.Get("Authorization")
if auth != "sso-key key:secret" {
http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized)
return
}
rw.WriteHeader(statusCode)
if statusCode == http.StatusNoContent {
return
}
file, err := os.Open(filepath.Join("fixtures", filename))
if err != nil {
http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
return
}
defer func() { _ = file.Close() }()
_, err = io.Copy(rw, file)
if err != nil {
http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError)
return
}
}
}

View file

@ -0,0 +1,4 @@
{
"code": "INVALID_BODY",
"message": "Request body doesn't fulfill schema, see details in `fields`"
}

View file

@ -0,0 +1,38 @@
[
{
"name":"_acme-challenge",
"type":"TXT",
"data":" ",
"ttl":600
},
{
"name":"_acme-challenge.example",
"type":"TXT",
"data":"6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU",
"ttl":600
},
{
"name":"_acme-challenge.example",
"type":"TXT",
"data":"8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek",
"ttl":600
},
{
"name":"_acme-challenge.lego",
"type":"TXT",
"data":" ",
"ttl":600
},
{
"name":"_acme-challenge.lego",
"type":"TXT",
"data":"0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A",
"ttl":600
},
{
"name":"_acme-challenge.lego",
"type":"TXT",
"data":"acme",
"ttl":600
}
]

View file

@ -0,0 +1,15 @@
package internal
// DNSRecord a DNS record.
type DNSRecord struct {
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Data string `json:"data"`
TTL int `json:"ttl,omitempty"`
Priority int `json:"priority,omitempty"`
Port int `json:"port,omitempty"`
Protocol string `json:"protocol,omitempty"`
Service string `json:"service,omitempty"`
Weight int `json:"weight,omitempty"`
}