Repository: xenolf/lego (go)

Issue:
Add support for ClouDNS
Add support for ClouDNS provider.

* API documentation: https://www.cloudns.net/wiki/article/42/


Pull request:
Add DNS Provider for ClouDNS.net
Adds Support for ClouDNS.net Subscribers with API access (see: https://www.cloudns.net/wiki/article/42/)

Notes:
The boilerplate and testing suite code is largely based off of cloudxns provider (`./providers/dns/cloudxns`) since it had an integrated client (ClouDNS has no golang client library). 
Works only with `auth-id` at the moment, but `sub-auth-id` support could be added.
Found that in practice 2 minutes is a good ttl to use with this provider, 1 minute ttl is too short.

Thanks for your time reviewing this pull request!

Closes #717

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1012bf00..8a4afbd5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -85,6 +85,7 @@ git push -u origin my-feature
 | Azure                     | `azure`        | [documentation](https://docs.microsoft.com/en-us/go/azure/)                                                  | [Go client](https://github.com/Azure/azure-sdk-for-go)            |
 | Bluecat                   | `bluecat`      | ?                                                                                                            | -                                                                 |
 | Cloudflare                | `cloudflare`   | [documentation](https://api.cloudflare.com/)                                                                 | [Go client](https://github.com/cloudflare/cloudflare-go)          |
+| ClouDNS                   | `cloudns`      | [documentation](https://www.cloudns.net/wiki/article/42/)                                                    | -                                                                 |
 | CloudXNS                  | `cloudxns`     | [documentation](https://www.cloudxns.net/Public/Doc/CloudXNS_api2.0_doc_zh-cn.zip)                           | -                                                                 |
 | ConoHa                    | `conoha`       | [documentation](https://www.conoha.jp/docs/)                                                                 | -                                                                 |
 | Openstack Designate       | `designate`    | [documentation](https://docs.openstack.org/designate/latest/)                                                | [Go client](https://godoc.org/github.com/gophercloud/gophercloud/openstack/dns/v2) |
diff --git a/cmd/cmd_dnshelp.go b/cmd/cmd_dnshelp.go
index 2e344c74..e54058aa 100644
--- a/cmd/cmd_dnshelp.go
+++ b/cmd/cmd_dnshelp.go
@@ -37,6 +37,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	fmt.Fprintln(w, "\tazure:\tAZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, AZURE_RESOURCE_GROUP")
 	fmt.Fprintln(w, "\tbluecat:\tBLUECAT_SERVER_URL, BLUECAT_USER_NAME, BLUECAT_PASSWORD, BLUECAT_CONFIG_NAME, BLUECAT_DNS_VIEW")
 	fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY")
+	fmt.Fprintln(w, "\tcloudns:\tCLOUDNS_AUTH_ID, CLOUDNS_AUTH_PASSWORD")
 	fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_API_KEY, CLOUDXNS_SECRET_KEY")
 	fmt.Fprintln(w, "\tconoha:\tCONOHA_TENANT_ID, CONOHA_API_USERNAME, CONOHA_API_PASSWORD")
 	fmt.Fprintln(w, "\tdesignate:\tOS_AUTH_URL, OS_USERNAME, OS_PASSWORD, OS_TENANT_NAME, OS_REGION_NAME")
@@ -92,6 +93,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	fmt.Fprintln(w, "\tazure:\tAZURE_POLLING_INTERVAL, AZURE_PROPAGATION_TIMEOUT, AZURE_TTL, AZURE_METADATA_ENDPOINT")
 	fmt.Fprintln(w, "\tbluecat:\tBLUECAT_POLLING_INTERVAL, BLUECAT_PROPAGATION_TIMEOUT, BLUECAT_TTL, BLUECAT_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_POLLING_INTERVAL, CLOUDFLARE_PROPAGATION_TIMEOUT, CLOUDFLARE_TTL, CLOUDFLARE_HTTP_TIMEOUT")
+	fmt.Fprintln(w, "\tcloudns:\tCLOUDNS_POLLING_INTERVAL, CLOUDNS_PROPAGATION_TIMEOUT, CLOUDNS_TTL, CLOUDNS_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_POLLING_INTERVAL, CLOUDXNS_PROPAGATION_TIMEOUT, CLOUDXNS_TTL, CLOUDXNS_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tconoha:\tCONOHA_POLLING_INTERVAL, CONOHA_PROPAGATION_TIMEOUT, CONOHA_TTL, CONOHA_HTTP_TIMEOUT, CONOHA_REGION")
 	fmt.Fprintln(w, "\tdesignate:\tDESIGNATE_POLLING_INTERVAL, DESIGNATE_PROPAGATION_TIMEOUT, DESIGNATE_TTL")
diff --git a/providers/dns/cloudns/cloudns.go b/providers/dns/cloudns/cloudns.go
new file mode 100644
index 00000000..6b4aaaa8
--- /dev/null
+++ b/providers/dns/cloudns/cloudns.go
@@ -0,0 +1,108 @@
+// Package cloudns implements a DNS provider for solving the DNS-01 challenge using ClouDNS DNS.
+package cloudns
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/xenolf/lego/challenge/dns01"
+	"github.com/xenolf/lego/platform/config/env"
+	"github.com/xenolf/lego/providers/dns/cloudns/internal"
+)
+
+// Config is used to configure the creation of the DNSProvider
+type Config struct {
+	AuthID             string
+	AuthPassword       string
+	PropagationTimeout time.Duration
+	PollingInterval    time.Duration
+	TTL                int
+	HTTPClient         *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider
+func NewDefaultConfig() *Config {
+	return &Config{
+		PropagationTimeout: env.GetOrDefaultSecond("CLOUDNS_PROPAGATION_TIMEOUT", 120*time.Second),
+		PollingInterval:    env.GetOrDefaultSecond("CLOUDNS_POLLING_INTERVAL", 4*time.Second),
+		TTL:                env.GetOrDefaultInt("CLOUDNS_TTL", dns01.DefaultTTL),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond("CLOUDNS_HTTP_TIMEOUT", 30*time.Second),
+		},
+	}
+}
+
+// DNSProvider is an implementation of the acme.ChallengeProvider interface
+type DNSProvider struct {
+	config *Config
+	client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for ClouDNS.
+// Credentials must be passed in the environment variables:
+// CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD.
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get("CLOUDNS_AUTH_ID", "CLOUDNS_AUTH_PASSWORD")
+	if err != nil {
+		return nil, fmt.Errorf("ClouDNS: %v", err)
+	}
+
+	config := NewDefaultConfig()
+	config.AuthID = values["CLOUDNS_AUTH_ID"]
+	config.AuthPassword = values["CLOUDNS_AUTH_PASSWORD"]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ClouDNS.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("ClouDNS: the configuration of the DNS provider is nil")
+	}
+
+	client, err := internal.NewClient(config.AuthID, config.AuthPassword)
+	if err != nil {
+		return nil, err
+	}
+
+	client.HTTPClient = config.HTTPClient
+
+	return &DNSProvider{client: client, config: config}, nil
+}
+
+// Present creates a TXT record to fulfill the dns-01 challenge.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	fqdn, value := dns01.GetRecord(domain, keyAuth)
+
+	zone, err := d.client.GetZone(fqdn)
+	if err != nil {
+		return err
+	}
+
+	return d.client.AddTxtRecord(zone.Name, fqdn, value, d.config.TTL)
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	fqdn, _ := dns01.GetRecord(domain, keyAuth)
+
+	zone, err := d.client.GetZone(fqdn)
+	if err != nil {
+		return err
+	}
+
+	record, err := d.client.FindTxtRecord(zone.Name, fqdn)
+	if err != nil {
+		return err
+	}
+
+	return d.client.RemoveTxtRecord(record.ID, zone.Name)
+}
+
+// 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
+}
diff --git a/providers/dns/cloudns/internal/client.go b/providers/dns/cloudns/internal/client.go
new file mode 100644
index 00000000..c3df3658
--- /dev/null
+++ b/providers/dns/cloudns/internal/client.go
@@ -0,0 +1,209 @@
+package internal
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	"github.com/xenolf/lego/challenge/dns01"
+)
+
+const defaultBaseURL = "https://api.cloudns.net/dns/"
+
+type Zone struct {
+	Name   string
+	Type   string
+	Zone   string
+	Status string // is an integer, but cast as string
+}
+
+// TXTRecord a TXT record
+type TXTRecord struct {
+	ID       int    `json:"id,string"`
+	Type     string `json:"type"`
+	Host     string `json:"host"`
+	Record   string `json:"record"`
+	Failover int    `json:"failover,string"`
+	TTL      int    `json:"ttl,string"`
+	Status   int    `json:"status"`
+}
+
+type TXTRecords map[string]TXTRecord
+
+// NewClient creates a ClouDNS client
+func NewClient(authID string, authPassword string) (*Client, error) {
+	if authID == "" {
+		return nil, fmt.Errorf("ClouDNS: credentials missing: authID")
+	}
+
+	if authPassword == "" {
+		return nil, fmt.Errorf("ClouDNS: credentials missing: authPassword")
+	}
+
+	baseURL, err := url.Parse(defaultBaseURL)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Client{
+		authID:       authID,
+		authPassword: authPassword,
+		HTTPClient:   &http.Client{},
+		BaseURL:      baseURL,
+	}, nil
+}
+
+// Client ClouDNS client
+type Client struct {
+	authID       string
+	authPassword string
+	HTTPClient   *http.Client
+	BaseURL      *url.URL
+}
+
+// GetZone Get domain name information for a FQDN
+func (c *Client) GetZone(authFQDN string) (*Zone, error) {
+	authZone, err := dns01.FindZoneByFqdn(authFQDN)
+	if err != nil {
+		return nil, err
+	}
+
+	authZoneName := dns01.UnFqdn(authZone)
+
+	reqURL := *c.BaseURL
+	reqURL.Path += "get-zone-info.json"
+
+	q := reqURL.Query()
+	q.Add("domain-name", authZoneName)
+	reqURL.RawQuery = q.Encode()
+
+	result, err := c.doRequest(http.MethodGet, &reqURL)
+	if err != nil {
+		return nil, err
+	}
+
+	var zone Zone
+
+	if len(result) > 0 {
+		if err = json.Unmarshal(result, &zone); err != nil {
+			return nil, fmt.Errorf("ClouDNS: zone unmarshaling error: %v", err)
+		}
+	}
+
+	if zone.Name == authZoneName {
+		return &zone, nil
+	}
+
+	return nil, fmt.Errorf("ClouDNS: zone %s not found for authFQDN %s", authZoneName, authFQDN)
+}
+
+// FindTxtRecord return the TXT record a zone ID and a FQDN
+func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) {
+	host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName))
+
+	reqURL := *c.BaseURL
+	reqURL.Path += "records.json"
+
+	q := reqURL.Query()
+	q.Add("domain-name", zoneName)
+	q.Add("host", host)
+	q.Add("type", "TXT")
+	reqURL.RawQuery = q.Encode()
+
+	result, err := c.doRequest(http.MethodGet, &reqURL)
+	if err != nil {
+		return nil, err
+	}
+
+	var records TXTRecords
+	if err = json.Unmarshal(result, &records); err != nil {
+		return nil, fmt.Errorf("ClouDNS: TXT record unmarshaling error: %v", err)
+	}
+
+	for _, record := range records {
+		if record.Host == host && record.Type == "TXT" {
+			return &record, nil
+		}
+	}
+
+	return nil, fmt.Errorf("ClouDNS: no existing record found for %q", fqdn)
+}
+
+// AddTxtRecord add a TXT record
+func (c *Client) AddTxtRecord(zoneName string, fqdn, value string, ttl int) error {
+	host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName))
+
+	reqURL := *c.BaseURL
+	reqURL.Path += "add-record.json"
+
+	q := reqURL.Query()
+	q.Add("domain-name", zoneName)
+	q.Add("host", host)
+	q.Add("record", value)
+	q.Add("ttl", strconv.Itoa(ttl))
+	q.Add("record-type", "TXT")
+	reqURL.RawQuery = q.Encode()
+
+	_, err := c.doRequest(http.MethodPost, &reqURL)
+	return err
+}
+
+// RemoveTxtRecord remove a TXT record
+func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error {
+	reqURL := *c.BaseURL
+	reqURL.Path += "delete-record.json"
+
+	q := reqURL.Query()
+	q.Add("domain-name", zoneName)
+	q.Add("record-id", strconv.Itoa(recordID))
+	reqURL.RawQuery = q.Encode()
+
+	_, err := c.doRequest(http.MethodPost, &reqURL)
+	return err
+}
+
+func (c *Client) doRequest(method string, url *url.URL) (json.RawMessage, error) {
+	req, err := c.buildRequest(method, url)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("ClouDNS: %v", err)
+	}
+
+	defer resp.Body.Close()
+
+	content, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("ClouDNS: %s", toUnreadableBodyMessage(req, content))
+	}
+
+	if resp.StatusCode != 200 {
+		return nil, fmt.Errorf("ClouDNS: invalid code (%v), error: %s", resp.StatusCode, content)
+	}
+	return content, nil
+}
+
+func (c *Client) buildRequest(method string, url *url.URL) (*http.Request, error) {
+	q := url.Query()
+	q.Add("auth-id", c.authID)
+	q.Add("auth-password", c.authPassword)
+	url.RawQuery = q.Encode()
+
+	req, err := http.NewRequest(method, url.String(), nil)
+	if err != nil {
+		return nil, fmt.Errorf("ClouDNS: invalid request: %v", err)
+	}
+
+	return req, nil
+}
+
+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 0a3a7d89..5fad1db4 100644
--- a/providers/dns/cloudxns/cloudxns.go
+++ b/providers/dns/cloudxns/cloudxns.go
@@ -29,7 +29,7 @@ func NewDefaultConfig() *Config {
 		PollingInterval:    env.GetOrDefaultSecond("CLOUDXNS_POLLING_INTERVAL", dns01.DefaultPollingInterval),
 		TTL:                env.GetOrDefaultInt("CLOUDXNS_TTL", dns01.DefaultTTL),
 		HTTPClient: &http.Client{
-			Timeout: time.Second * time.Duration(env.GetOrDefaultInt("CLOUDXNS_HTTP_TIMEOUT", 30)),
+			Timeout: env.GetOrDefaultSecond("CLOUDXNS_HTTP_TIMEOUT", 30*time.Second),
 		},
 	}
 }
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index 979fa03e..2846ee58 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -11,6 +11,7 @@ import (
 	"github.com/xenolf/lego/providers/dns/azure"
 	"github.com/xenolf/lego/providers/dns/bluecat"
 	"github.com/xenolf/lego/providers/dns/cloudflare"
+	"github.com/xenolf/lego/providers/dns/cloudns"
 	"github.com/xenolf/lego/providers/dns/cloudxns"
 	"github.com/xenolf/lego/providers/dns/conoha"
 	"github.com/xenolf/lego/providers/dns/designate"
@@ -74,6 +75,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
 		return bluecat.NewDNSProvider()
 	case "cloudflare":
 		return cloudflare.NewDNSProvider()
+	case "cloudns":
+		return cloudns.NewDNSProvider()
 	case "cloudxns":
 		return cloudxns.NewDNSProvider()
 	case "conoha":


Test patch:
diff --git a/providers/dns/cloudns/cloudns_test.go b/providers/dns/cloudns/cloudns_test.go
new file mode 100644
index 00000000..0a1da286
--- /dev/null
+++ b/providers/dns/cloudns/cloudns_test.go
@@ -0,0 +1,150 @@
+package cloudns
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"github.com/xenolf/lego/platform/tester"
+)
+
+var envTest = tester.NewEnvTest(
+	"CLOUDNS_AUTH_ID",
+	"CLOUDNS_AUTH_PASSWORD").
+	WithDomain("CLOUDNS_DOMAIN")
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "success",
+			envVars: map[string]string{
+				"CLOUDNS_AUTH_ID":       "123",
+				"CLOUDNS_AUTH_PASSWORD": "456",
+			},
+		},
+		{
+			desc: "missing credentials",
+			envVars: map[string]string{
+				"CLOUDNS_AUTH_ID":       "",
+				"CLOUDNS_AUTH_PASSWORD": "",
+			},
+			expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_ID,CLOUDNS_AUTH_PASSWORD",
+		},
+		{
+			desc: "missing auth-id",
+			envVars: map[string]string{
+				"CLOUDNS_AUTH_ID":       "",
+				"CLOUDNS_AUTH_PASSWORD": "456",
+			},
+			expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_ID",
+		},
+		{
+			desc: "missing auth-password",
+			envVars: map[string]string{
+				"CLOUDNS_AUTH_ID":       "123",
+				"CLOUDNS_AUTH_PASSWORD": "",
+			},
+			expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_PASSWORD",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			defer envTest.RestoreEnv()
+			envTest.ClearEnv()
+
+			envTest.Apply(test.envVars)
+
+			p, err := NewDNSProvider()
+
+			if len(test.expected) == 0 {
+				require.NoError(t, err)
+				require.NotNil(t, p)
+				require.NotNil(t, p.config)
+				require.NotNil(t, p.client)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+	testCases := []struct {
+		desc         string
+		authID       string
+		authPassword string
+		expected     string
+	}{
+		{
+			desc:         "success",
+			authID:       "123",
+			authPassword: "456",
+		},
+		{
+			desc:     "missing credentials",
+			expected: "ClouDNS: credentials missing: authID",
+		},
+		{
+			desc:         "missing auth-id",
+			authPassword: "456",
+			expected:     "ClouDNS: credentials missing: authID",
+		},
+		{
+			desc:     "missing auth-password",
+			authID:   "123",
+			expected: "ClouDNS: credentials missing: authPassword",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.AuthID = test.authID
+			config.AuthPassword = test.authPassword
+
+			p, err := NewDNSProviderConfig(config)
+
+			if len(test.expected) == 0 {
+				require.NoError(t, err)
+				require.NotNil(t, p)
+				require.NotNil(t, p.config)
+				require.NotNil(t, p.client)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestLivePresent(t *testing.T) {
+	if !envTest.IsLiveTest() {
+		t.Skip("skipping live test")
+	}
+
+	envTest.RestoreEnv()
+	provider, err := NewDNSProvider()
+	require.NoError(t, err)
+
+	err = provider.Present(envTest.GetDomain(), "", "123d==")
+	require.NoError(t, err)
+}
+
+func TestLiveCleanUp(t *testing.T) {
+	if !envTest.IsLiveTest() {
+		t.Skip("skipping live test")
+	}
+
+	envTest.RestoreEnv()
+	provider, err := NewDNSProvider()
+	require.NoError(t, err)
+
+	time.Sleep(2 * time.Second)
+
+	err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+	require.NoError(t, err)
+}
diff --git a/providers/dns/cloudns/internal/client_test.go b/providers/dns/cloudns/internal/client_test.go
new file mode 100644
index 00000000..674e8a6c
--- /dev/null
+++ b/providers/dns/cloudns/internal/client_test.go
@@ -0,0 +1,194 @@
+package internal
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func handlerMock(method string, jsonData []byte) http.Handler {
+	return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		if req.Method != method {
+			http.Error(rw, "Incorrect method used", http.StatusBadRequest)
+			return
+		}
+
+		_, err := rw.Write(jsonData)
+		if err != nil {
+			http.Error(rw, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	})
+}
+
+func TestClientGetZone(t *testing.T) {
+	type result struct {
+		zone  *Zone
+		error bool
+	}
+	testCases := []struct {
+		desc        string
+		authFQDN    string
+		apiResponse []byte
+		expected    result
+	}{
+		{
+			desc:        "zone found",
+			authFQDN:    "_acme-challenge.foo.com.",
+			apiResponse: []byte(`{"name": "foo.com", "type": "master", "zone": "zone", "status": "1"}`),
+			expected: result{
+				zone: &Zone{
+					Name:   "foo.com",
+					Type:   "master",
+					Zone:   "zone",
+					Status: "1",
+				},
+			},
+		},
+		{
+			desc:        "zone not found",
+			authFQDN:    "_acme-challenge.foo.com.",
+			apiResponse: []byte(``),
+			expected:    result{error: true},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			server := httptest.NewServer(handlerMock(http.MethodGet, test.apiResponse))
+
+			client, _ := NewClient("myAuthID", "myAuthPassword")
+			mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL))
+			client.BaseURL = mockBaseURL
+
+			zone, err := client.GetZone(test.authFQDN)
+
+			if test.expected.error {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, test.expected.zone, zone)
+			}
+		})
+	}
+}
+
+func TestClientFindTxtRecord(t *testing.T) {
+	type result struct {
+		txtRecord *TXTRecord
+		error     bool
+	}
+
+	testCases := []struct {
+		desc        string
+		authFQDN    string
+		zoneName    string
+		apiResponse []byte
+		expected    result
+	}{
+		{
+			desc:        "record found",
+			authFQDN:    "_acme-challenge.foo.com.",
+			zoneName:    "foo.com",
+			apiResponse: []byte(`{"1":{"id":"1","type":"TXT","host":"_acme-challenge","record":"txtTXTtxtTXTtxtTXTtxtTXT","failover":"1","ttl":"30","status":1}}`),
+			expected: result{
+				txtRecord: &TXTRecord{
+					ID:       1,
+					Type:     "TXT",
+					Host:     "_acme-challenge",
+					Record:   "txtTXTtxtTXTtxtTXTtxtTXT",
+					Failover: 1,
+					TTL:      30,
+					Status:   1,
+				},
+			},
+		},
+		{
+			desc:        "record not found",
+			authFQDN:    "_acme-challenge.foo.com.",
+			zoneName:    "test-zone",
+			apiResponse: []byte(``),
+			expected:    result{error: true},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			server := httptest.NewServer(handlerMock(http.MethodGet, test.apiResponse))
+
+			client, _ := NewClient("myAuthID", "myAuthPassword")
+			mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL))
+			client.BaseURL = mockBaseURL
+
+			txtRecord, err := client.FindTxtRecord(test.zoneName, test.authFQDN)
+
+			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
+		zone     *Zone
+		authFQDN string
+		value    string
+		ttl      int
+		expected string
+	}{
+		{
+			desc: "sub-zone",
+			zone: &Zone{
+				Name:   "bar.com",
+				Type:   "master",
+				Zone:   "domain",
+				Status: "1",
+			},
+			authFQDN: "_acme-challenge.foo.bar.com.",
+			value:    "txtTXTtxtTXTtxtTXTtxtTXT",
+			ttl:      60,
+			expected: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`,
+		},
+		{
+			desc: "main zone",
+			zone: &Zone{
+				Name:   "bar.com",
+				Type:   "master",
+				Zone:   "domain",
+				Status: "1",
+			},
+			authFQDN: "_acme-challenge.bar.com.",
+			value:    "TXTtxtTXTtxtTXTtxtTXTtxt",
+			ttl:      60,
+			expected: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+				assert.NotNil(t, req.URL.RawQuery)
+				assert.Equal(t, test.expected, req.URL.RawQuery)
+
+				handlerMock(http.MethodPost, nil).ServeHTTP(rw, req)
+			}))
+
+			client, _ := NewClient("myAuthID", "myAuthPassword")
+			mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL))
+			client.BaseURL = mockBaseURL
+
+			err := client.AddTxtRecord(test.zone.Name, test.authFQDN, test.value, test.ttl)
+			require.NoError(t, err)
+		})
+	}
+}