Repository: xenolf/lego (go)

Issue:
Wrong host record in CloudXNS
When I use [Caddy](https://caddyserver.com/) as an HTTP server in DNS challenge mode, it cannot get certificate of my domain hosted by [CloudXNS](https://www.cloudxns.net/).

It seems that there is something wrong with record name generated by Lego.
The host record is set to `_acme-challenge.example.com`.
<img width="984" alt="qq20180816-175531 2x" src="https://user-images.githubusercontent.com/3366499/44202098-9eae3100-a17d-11e8-9ea3-fd258f126ea8.png">

But CloudXNS will resolve it to `_acme-challenge.example.com.example.com`.
<img width="825" alt="qq20180816-174143 2x" src="https://user-images.githubusercontent.com/3366499/44201391-b08ed480-a17b-11e8-9e1b-36ba70ec869e.png">

According to [the document of CloudXNS](https://www.cloudxns.net/Public/Doc/CloudXNS_api2.0_doc_zh-cn.zip) (Chinese version only, sad...), the key `host` **SHOULD NOT** contain the domain again.
For example `_acme-challenge` means `_acme-challenge.example.com` and `_acme-challenge.foo` means `_acme-challenge.foo.example.com`.

Since I don't know GO, I cannot bring a PR. But I think the problem is here:
[https://github.com/xenolf/lego/blob/master/providers/dns/cloudxns/cloudxns.go#L139](https://github.com/xenolf/lego/blob/master/providers/dns/cloudxns/cloudxns.go#L139)

Hoping you can fix it. Thanks a lot.


Pull request description:
CloudXNS: client isolation.
Client isolation to facilitate testing.

Fixes #619 

Patch:
diff --git a/providers/dns/cloudxns/client.go b/providers/dns/cloudxns/client.go
new file mode 100644
index 00000000..4cf1dd20
--- /dev/null
+++ b/providers/dns/cloudxns/client.go
@@ -0,0 +1,208 @@
+package cloudxns
+
+import (
+	"bytes"
+	"crypto/md5"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/xenolf/lego/acme"
+)
+
+const defaultBaseURL = "https://www.cloudxns.net/api2/"
+
+type apiResponse struct {
+	Code    int             `json:"code"`
+	Message string          `json:"message"`
+	Data    json.RawMessage `json:"data,omitempty"`
+}
+
+// Data Domain information
+type Data struct {
+	ID     string `json:"id"`
+	Domain string `json:"domain"`
+	TTL    int    `json:"ttl,omitempty"`
+}
+
+// TXTRecord a TXT record
+type TXTRecord struct {
+	ID       int    `json:"domain_id,omitempty"`
+	RecordID string `json:"record_id,omitempty"`
+
+	Host   string `json:"host"`
+	Value  string `json:"value"`
+	Type   string `json:"type"`
+	LineID int    `json:"line_id,string"`
+	TTL    int    `json:"ttl,string"`
+}
+
+// NewClient creates a CloudXNS client
+func NewClient(apiKey string, secretKey string) (*Client, error) {
+	if apiKey == "" {
+		return nil, fmt.Errorf("CloudXNS: credentials missing: apiKey")
+	}
+
+	if secretKey == "" {
+		return nil, fmt.Errorf("CloudXNS: credentials missing: secretKey")
+	}
+
+	return &Client{
+		apiKey:     apiKey,
+		secretKey:  secretKey,
+		HTTPClient: &http.Client{},
+		BaseURL:    defaultBaseURL,
+	}, nil
+}
+
+// Client CloudXNS client
+type Client struct {
+	apiKey     string
+	secretKey  string
+	HTTPClient *http.Client
+	BaseURL    string
+}
+
+// GetDomainInformation Get domain name information for a FQDN
+func (c *Client) GetDomainInformation(fqdn string) (*Data, error) {
+	authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers)
+	if err != nil {
+		return nil, err
+	}
+
+	result, err := c.doRequest(http.MethodGet, "domain", nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var domains []Data
+	if len(result) > 0 {
+		err = json.Unmarshal(result, &domains)
+		if err != nil {
+			return nil, fmt.Errorf("CloudXNS: domains unmarshaling error: %v", err)
+		}
+	}
+
+	for _, data := range domains {
+		if data.Domain == authZone {
+			return &data, nil
+		}
+	}
+
+	return nil, fmt.Errorf("CloudXNS: zone %s not found for domain %s", authZone, fqdn)
+}
+
+// FindTxtRecord return the TXT record a zone ID and a FQDN
+func (c *Client) FindTxtRecord(zoneID, fqdn string) (*TXTRecord, error) {
+	result, err := c.doRequest(http.MethodGet, fmt.Sprintf("record/%s?host_id=0&offset=0&row_num=2000", zoneID), nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var records []TXTRecord
+	err = json.Unmarshal(result, &records)
+	if err != nil {
+		return nil, fmt.Errorf("CloudXNS: TXT record unmarshaling error: %v", err)
+	}
+
+	for _, record := range records {
+		if record.Host == acme.UnFqdn(fqdn) && record.Type == "TXT" {
+			return &record, nil
+		}
+	}
+
+	return nil, fmt.Errorf("CloudXNS: no existing record found for %q", fqdn)
+}
+
+// AddTxtRecord add a TXT record
+func (c *Client) AddTxtRecord(info *Data, fqdn, value string, ttl int) error {
+	id, err := strconv.Atoi(info.ID)
+	if err != nil {
+		return fmt.Errorf("CloudXNS: invalid zone ID: %v", err)
+	}
+
+	payload := TXTRecord{
+		ID:     id,
+		Host:   acme.UnFqdn(strings.TrimSuffix(fqdn, info.Domain)),
+		Value:  value,
+		Type:   "TXT",
+		LineID: 1,
+		TTL:    ttl,
+	}
+
+	body, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("CloudXNS: record unmarshaling error: %v", err)
+	}
+
+	_, err = c.doRequest(http.MethodPost, "record", body)
+	return err
+}
+
+// RemoveTxtRecord remove a TXT record
+func (c *Client) RemoveTxtRecord(recordID, zoneID string) error {
+	_, err := c.doRequest(http.MethodDelete, fmt.Sprintf("record/%s/%s", recordID, zoneID), nil)
+	return err
+}
+
+func (c *Client) doRequest(method, uri string, body []byte) (json.RawMessage, error) {
+	req, err := c.buildRequest(method, uri, body)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("CloudXNS: %v", err)
+	}
+
+	defer resp.Body.Close()
+
+	content, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("CloudXNS: %s", toUnreadableBodyMessage(req, content))
+	}
+
+	var r apiResponse
+	err = json.Unmarshal(content, &r)
+	if err != nil {
+		return nil, fmt.Errorf("CloudXNS: response unmashaling error: %v: %s", err, toUnreadableBodyMessage(req, content))
+	}
+
+	if r.Code != 1 {
+		return nil, fmt.Errorf("CloudXNS: invalid code (%v), error: %s", r.Code, r.Message)
+	}
+	return r.Data, nil
+}
+
+func (c *Client) buildRequest(method, uri string, body []byte) (*http.Request, error) {
+	url := c.BaseURL + uri
+
+	req, err := http.NewRequest(method, url, bytes.NewReader(body))
+	if err != nil {
+		return nil, fmt.Errorf("CloudXNS: invalid request: %v", err)
+	}
+
+	requestDate := time.Now().Format(time.RFC1123Z)
+
+	req.Header.Set("API-KEY", c.apiKey)
+	req.Header.Set("API-REQUEST-DATE", requestDate)
+	req.Header.Set("API-HMAC", c.hmac(url, requestDate, string(body)))
+	req.Header.Set("API-FORMAT", "json")
+
+	return req, nil
+}
+
+func (c *Client) hmac(url, date, body string) string {
+	sum := md5.Sum([]byte(c.apiKey + url + body + date + c.secretKey))
+	return hex.EncodeToString(sum[:])
+}
+
+func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
+	return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody))
+}
diff --git a/providers/dns/cloudxns/cloudxns.go b/providers/dns/cloudxns/cloudxns.go
index ae819f12..4b73564a 100644
--- a/providers/dns/cloudxns/cloudxns.go
+++ b/providers/dns/cloudxns/cloudxns.go
@@ -1,32 +1,49 @@
 // Package cloudxns implements a DNS provider for solving the DNS-01 challenge
-// using cloudxns DNS.
+// using CloudXNS DNS.
 package cloudxns
 
 import (
-	"bytes"
-	"crypto/md5"
-	"encoding/hex"
-	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
-	"strconv"
 	"time"
 
 	"github.com/xenolf/lego/acme"
 	"github.com/xenolf/lego/platform/config/env"
 )
 
-const cloudXNSBaseURL = "https://www.cloudxns.net/api2/"
+// Config is used to configure the creation of the DNSProvider
+type Config struct {
+	APIKey             string
+	SecretKey          string
+	PropagationTimeout time.Duration
+	PollingInterval    time.Duration
+	TTL                int
+	HTTPClient         *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider
+func NewDefaultConfig() *Config {
+	client := acme.HTTPClient
+	client.Timeout = time.Second * time.Duration(env.GetOrDefaultInt("CLOUDXNS_HTTP_TIMEOUT", 30))
+
+	return &Config{
+		PropagationTimeout: env.GetOrDefaultSecond("AKAMAI_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout),
+		PollingInterval:    env.GetOrDefaultSecond("AKAMAI_POLLING_INTERVAL", acme.DefaultPollingInterval),
+		TTL:                env.GetOrDefaultInt("CLOUDXNS_TTL", 120),
+		HTTPClient:         &client,
+	}
+}
 
 // DNSProvider is an implementation of the acme.ChallengeProvider interface
 type DNSProvider struct {
-	apiKey    string
-	secretKey string
+	config *Config
+	client *Client
 }
 
-// NewDNSProvider returns a DNSProvider instance configured for cloudxns.
-// Credentials must be passed in the environment variables: CLOUDXNS_API_KEY
-// and CLOUDXNS_SECRET_KEY.
+// NewDNSProvider returns a DNSProvider instance configured for CloudXNS.
+// Credentials must be passed in the environment variables:
+// CLOUDXNS_API_KEY and CLOUDXNS_SECRET_KEY.
 func NewDNSProvider() (*DNSProvider, error) {
 	values, err := env.Get("CLOUDXNS_API_KEY", "CLOUDXNS_SECRET_KEY")
 	if err != nil {
@@ -37,177 +54,62 @@ func NewDNSProvider() (*DNSProvider, error) {
 }
 
 // NewDNSProviderCredentials uses the supplied credentials to return a
-// DNSProvider instance configured for cloudxns.
+// DNSProvider instance configured for CloudXNS.
 func NewDNSProviderCredentials(apiKey, secretKey string) (*DNSProvider, error) {
-	if apiKey == "" || secretKey == "" {
-		return nil, fmt.Errorf("CloudXNS credentials missing")
-	}
+	config := NewDefaultConfig()
+	config.APIKey = apiKey
+	config.SecretKey = secretKey
 
-	return &DNSProvider{
-		apiKey:    apiKey,
-		secretKey: secretKey,
-	}, nil
+	return NewDNSProviderConfig(config)
 }
 
-// Present creates a TXT record to fulfil the dns-01 challenge.
-func (d *DNSProvider) Present(domain, token, keyAuth string) error {
-	fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
-	zoneID, err := d.getHostedZoneID(fqdn)
-	if err != nil {
-		return err
+// NewDNSProviderConfig return a DNSProvider instance configured for CloudXNS.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("CloudXNS: the configuration of the DNS provider is nil")
 	}
 
-	return d.addTxtRecord(zoneID, fqdn, value, ttl)
-}
-
-// CleanUp removes the TXT record matching the specified parameters.
-func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
-	fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
-	zoneID, err := d.getHostedZoneID(fqdn)
+	client, err := NewClient(config.APIKey, config.SecretKey)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	recordID, err := d.findTxtRecord(zoneID, fqdn)
-	if err != nil {
-		return err
-	}
+	client.HTTPClient = config.HTTPClient
 
-	return d.delTxtRecord(recordID, zoneID)
+	return &DNSProvider{client: client}, nil
 }
 
-func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) {
-	type Data struct {
-		ID     string `json:"id"`
-		Domain string `json:"domain"`
-	}
-
-	authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers)
-	if err != nil {
-		return "", err
-	}
-
-	result, err := d.makeRequest(http.MethodGet, "domain", nil)
-	if err != nil {
-		return "", err
-	}
+// Present creates a TXT record to fulfil the dns-01 challenge.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
 
-	var domains []Data
-	err = json.Unmarshal(result, &domains)
+	info, err := d.client.GetDomainInformation(fqdn)
 	if err != nil {
-		return "", err
-	}
-
-	for _, data := range domains {
-		if data.Domain == authZone {
-			return data.ID, nil
-		}
+		return err
 	}
 
-	return "", fmt.Errorf("zone %s not found in cloudxns for domain %s", authZone, fqdn)
+	return d.client.AddTxtRecord(info, fqdn, value, d.config.TTL)
 }
 
-func (d *DNSProvider) findTxtRecord(zoneID, fqdn string) (string, error) {
-	result, err := d.makeRequest(http.MethodGet, fmt.Sprintf("record/%s?host_id=0&offset=0&row_num=2000", zoneID), nil)
-	if err != nil {
-		return "", err
-	}
-
-	var records []cloudXNSRecord
-	err = json.Unmarshal(result, &records)
-	if err != nil {
-		return "", err
-	}
-
-	for _, record := range records {
-		if record.Host == acme.UnFqdn(fqdn) && record.Type == "TXT" {
-			return record.RecordID, nil
-		}
-	}
-
-	return "", fmt.Errorf("no existing record found for %s", fqdn)
-}
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
 
-func (d *DNSProvider) addTxtRecord(zoneID, fqdn, value string, ttl int) error {
-	id, err := strconv.Atoi(zoneID)
+	info, err := d.client.GetDomainInformation(fqdn)
 	if err != nil {
 		return err
 	}
 
-	payload := cloudXNSRecord{
-		ID:     id,
-		Host:   acme.UnFqdn(fqdn),
-		Value:  value,
-		Type:   "TXT",
-		LineID: 1,
-		TTL:    ttl,
-	}
-
-	body, err := json.Marshal(payload)
+	record, err := d.client.FindTxtRecord(info.ID, fqdn)
 	if err != nil {
 		return err
 	}
 
-	_, err = d.makeRequest(http.MethodPost, "record", body)
-	return err
-}
-
-func (d *DNSProvider) delTxtRecord(recordID, zoneID string) error {
-	_, err := d.makeRequest(http.MethodDelete, fmt.Sprintf("record/%s/%s", recordID, zoneID), nil)
-	return err
-}
-
-func (d *DNSProvider) hmac(url, date, body string) string {
-	sum := md5.Sum([]byte(d.apiKey + url + body + date + d.secretKey))
-	return hex.EncodeToString(sum[:])
+	return d.client.RemoveTxtRecord(record.RecordID, info.ID)
 }
 
-func (d *DNSProvider) makeRequest(method, uri string, body []byte) (json.RawMessage, error) {
-	type APIResponse struct {
-		Code    int             `json:"code"`
-		Message string          `json:"message"`
-		Data    json.RawMessage `json:"data,omitempty"`
-	}
-
-	url := cloudXNSBaseURL + uri
-	req, err := http.NewRequest(method, url, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-
-	requestDate := time.Now().Format(time.RFC1123Z)
-
-	req.Header.Set("API-KEY", d.apiKey)
-	req.Header.Set("API-REQUEST-DATE", requestDate)
-	req.Header.Set("API-HMAC", d.hmac(url, requestDate, string(body)))
-	req.Header.Set("API-FORMAT", "json")
-
-	resp, err := acme.HTTPClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-
-	defer resp.Body.Close()
-
-	var r APIResponse
-	err = json.NewDecoder(resp.Body).Decode(&r)
-	if err != nil {
-		return nil, err
-	}
-
-	if r.Code != 1 {
-		return nil, fmt.Errorf("CloudXNS API Error: %s", r.Message)
-	}
-	return r.Data, nil
-}
-
-type cloudXNSRecord struct {
-	ID       int    `json:"domain_id,omitempty"`
-	RecordID string `json:"record_id,omitempty"`
-
-	Host   string `json:"host"`
-	Value  string `json:"value"`
-	Type   string `json:"type"`
-	LineID int    `json:"line_id,string"`
-	TTL    int    `json:"ttl,string"`
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+	return d.config.PropagationTimeout, d.config.PollingInterval
 }


Test patch:
diff --git a/providers/dns/cloudxns/client_test.go b/providers/dns/cloudxns/client_test.go
new file mode 100644
index 00000000..5c41c6d7
--- /dev/null
+++ b/providers/dns/cloudxns/client_test.go
@@ -0,0 +1,283 @@
+package cloudxns
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func handlerMock(method string, response *apiResponse, data interface{}) http.Handler {
+	return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			content, err := json.Marshal(apiResponse{
+				Code:    999, // random code only for the test
+				Message: fmt.Sprintf("invalid method: got %s want %s", req.Method, method),
+			})
+			if err != nil {
+				http.Error(rw, err.Error(), http.StatusInternalServerError)
+				return
+			}
+
+			http.Error(rw, string(content), http.StatusBadRequest)
+			return
+		}
+
+		jsonData, err := json.Marshal(data)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		response.Data = jsonData
+
+		content, err := json.Marshal(response)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		rw.Write(content)
+	})
+}
+
+func TestClientGetDomainInformation(t *testing.T) {
+	type result struct {
+		domain *Data
+		error  bool
+	}
+
+	testCases := []struct {
+		desc     string
+		fqdn     string
+		response *apiResponse
+		data     []Data
+		expected result
+	}{
+		{
+			desc: "domain found",
+			fqdn: "_acme-challenge.foo.com.",
+			response: &apiResponse{
+				Code: 1,
+			},
+			data: []Data{
+				{
+					ID:     "1",
+					Domain: "bar.com.",
+				},
+				{
+					ID:     "2",
+					Domain: "foo.com.",
+				},
+			},
+			expected: result{domain: &Data{
+				ID:     "2",
+				Domain: "foo.com.",
+			}},
+		},
+		{
+			desc: "domains not found",
+			fqdn: "_acme-challenge.huu.com.",
+			response: &apiResponse{
+				Code: 1,
+			},
+			data: []Data{
+				{
+					ID:     "5",
+					Domain: "bar.com.",
+				},
+				{
+					ID:     "6",
+					Domain: "foo.com.",
+				},
+			},
+			expected: result{error: true},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+
+			server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.data))
+
+			client, _ := NewClient("myKey", "mySecret")
+			client.BaseURL = server.URL + "/"
+
+			domain, err := client.GetDomainInformation(test.fqdn)
+
+			if test.expected.error {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, test.expected.domain, domain)
+			}
+		})
+	}
+}
+
+func TestClientFindTxtRecord(t *testing.T) {
+	type result struct {
+		txtRecord *TXTRecord
+		error     bool
+	}
+
+	testCases := []struct {
+		desc       string
+		fqdn       string
+		zoneID     string
+		txtRecords []TXTRecord
+		response   *apiResponse
+		expected   result
+	}{
+		{
+			desc:   "record found",
+			fqdn:   "_acme-challenge.foo.com.",
+			zoneID: "test-zone",
+			txtRecords: []TXTRecord{
+				{
+					ID:       1,
+					RecordID: "Record-A",
+					Host:     "_acme-challenge.foo.com",
+					Value:    "txtTXTtxtTXTtxtTXTtxtTXT",
+					Type:     "TXT",
+					LineID:   6,
+					TTL:      30,
+				},
+				{
+					ID:       2,
+					RecordID: "Record-B",
+					Host:     "_acme-challenge.bar.com",
+					Value:    "TXTtxtTXTtxtTXTtxtTXTtxt",
+					Type:     "TXT",
+					LineID:   6,
+					TTL:      30,
+				},
+			},
+			response: &apiResponse{
+				Code: 1,
+			},
+			expected: result{
+				txtRecord: &TXTRecord{
+					ID:       1,
+					RecordID: "Record-A",
+					Host:     "_acme-challenge.foo.com",
+					Value:    "txtTXTtxtTXTtxtTXTtxtTXT",
+					Type:     "TXT",
+					LineID:   6,
+					TTL:      30,
+				},
+			},
+		},
+		{
+			desc:   "record not found",
+			fqdn:   "_acme-challenge.huu.com.",
+			zoneID: "test-zone",
+			txtRecords: []TXTRecord{
+				{
+					ID:       1,
+					RecordID: "Record-A",
+					Host:     "_acme-challenge.foo.com",
+					Value:    "txtTXTtxtTXTtxtTXTtxtTXT",
+					Type:     "TXT",
+					LineID:   6,
+					TTL:      30,
+				},
+				{
+					ID:       2,
+					RecordID: "Record-B",
+					Host:     "_acme-challenge.bar.com",
+					Value:    "TXTtxtTXTtxtTXTtxtTXTtxt",
+					Type:     "TXT",
+					LineID:   6,
+					TTL:      30,
+				},
+			},
+			response: &apiResponse{
+				Code: 1,
+			},
+			expected: result{error: true},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+
+			server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.txtRecords))
+
+			client, _ := NewClient("myKey", "mySecret")
+			client.BaseURL = server.URL + "/"
+
+			txtRecord, err := client.FindTxtRecord(test.zoneID, test.fqdn)
+
+			if test.expected.error {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, test.expected.txtRecord, txtRecord)
+			}
+		})
+	}
+}
+
+func TestClientAddTxtRecord(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		domain   *Data
+		fqdn     string
+		value    string
+		ttl      int
+		expected string
+	}{
+		{
+			desc: "sub-domain",
+			domain: &Data{
+				ID:     "1",
+				Domain: "bar.com.",
+			},
+			fqdn:     "_acme-challenge.foo.bar.com.",
+			value:    "txtTXTtxtTXTtxtTXTtxtTXT",
+			ttl:      30,
+			expected: `{"domain_id":1,"host":"_acme-challenge.foo","value":"txtTXTtxtTXTtxtTXTtxtTXT","type":"TXT","line_id":"1","ttl":"30"}`,
+		},
+		{
+			desc: "main domain",
+			domain: &Data{
+				ID:     "2",
+				Domain: "bar.com.",
+			},
+			fqdn:     "_acme-challenge.bar.com.",
+			value:    "TXTtxtTXTtxtTXTtxtTXTtxt",
+			ttl:      30,
+			expected: `{"domain_id":2,"host":"_acme-challenge","value":"TXTtxtTXTtxtTXTtxtTXTtxt","type":"TXT","line_id":"1","ttl":"30"}`,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			response := &apiResponse{
+				Code: 1,
+			}
+
+			server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+				assert.NotNil(t, req.Body)
+				content, err := ioutil.ReadAll(req.Body)
+				require.NoError(t, err)
+
+				assert.Equal(t, test.expected, string(content))
+
+				handlerMock(http.MethodPost, response, nil).ServeHTTP(rw, req)
+			}))
+
+			client, _ := NewClient("myKey", "mySecret")
+			client.BaseURL = server.URL + "/"
+
+			err := client.AddTxtRecord(test.domain, test.fqdn, test.value, test.ttl)
+			require.NoError(t, err)
+		})
+	}
+}