Repository: go-acme/lego
Pull request #2584 (2025-07-13 15:08:42)

chore: replace official Civo API client by an internal API client
Currently, we cannot update to the latest version of `github.com/civo/civogo` because it has dependencies on k8s.

- https://github.com/civo/civogo/blob/v0.2.99/go.mod -> k8s.io/client-go
- https://github.com/civo/civogo/blob/v0.3.34/go.mod -> k8s.io/api
- https://github.com/civo/civogo/blob/v0.3.38/go.mod -> k8s.io/api + k8s.io/apimachinery
- Current version -> https://github.com/civo/civogo/blob/v0.6.1/go.mod

The k8s dependencies are intricate inside the official client, I didn't find an easy forking automation to remove the dependencies.

It's useless to depend on the official client because we cannot follow it, so we will not be able to get bug fixes.

We can use a thin client based on the API documentation.

I did the migration step by step (one commit per step):
1. Create tests on the current `DNSProvider` by using the new mock system and using data from the API documentation to create fixture files.
2. Create a new client based on the tests created during the first step and the API documentation.
3. Replace the official client with the internal client.
4. Clean fixture files.

Each step/commit works (build and tests); there is no partial state to ensure no regression.



Patch:
diff --git a/go.mod b/go.mod
index e5474f48..ba66c7c3 100644
--- a/go.mod
+++ b/go.mod
@@ -29,7 +29,6 @@ require (
 	github.com/aziontech/azionapi-go-sdk v0.142.0
 	github.com/baidubce/bce-sdk-go v0.9.223
 	github.com/cenkalti/backoff/v4 v4.3.0
-	github.com/civo/civogo v0.3.11
 	github.com/dnsimple/dnsimple-go/v4 v4.0.0
 	github.com/exoscale/egoscale/v3 v3.1.13
 	github.com/go-jose/go-jose/v4 v4.0.5
diff --git a/go.sum b/go.sum
index 417cc153..e62ae05c 100644
--- a/go.sum
+++ b/go.sum
@@ -243,8 +243,6 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
 github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
-github.com/civo/civogo v0.3.11 h1:mON/fyrV946Sbk6paRtOSGsN+asCgCmHCgArf5xmGxM=
-github.com/civo/civogo v0.3.11/go.mod h1:7+GeeFwc4AYTULaEshpT2vIcl3Qq8HPoxA17viX3l6g=
 github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
 github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E=
 github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
@@ -724,7 +722,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
 github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
 github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
 github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
-github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
 github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
 github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
@@ -1107,7 +1104,6 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
diff --git a/providers/dns/civo/civo.go b/providers/dns/civo/civo.go
index e2ee41bd..46c474b5 100644
--- a/providers/dns/civo/civo.go
+++ b/providers/dns/civo/civo.go
@@ -2,14 +2,16 @@
 package civo
 
 import (
+	"context"
 	"errors"
 	"fmt"
+	"net/http"
 	"time"
 
-	"github.com/civo/civogo"
 	"github.com/go-acme/lego/v4/challenge"
 	"github.com/go-acme/lego/v4/challenge/dns01"
 	"github.com/go-acme/lego/v4/platform/config/env"
+	"github.com/go-acme/lego/v4/providers/dns/civo/internal"
 )
 
 // Environment variables names.
@@ -21,6 +23,7 @@ const (
 	EnvTTL                = envNamespace + "TTL"
 	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
 	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
+	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
 )
 
 const (
@@ -33,11 +36,12 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
 
 // Config is used to configure the creation of the DNSProvider.
 type Config struct {
-	ProjectID          string
-	Token              string
+	Token string
+
 	PropagationTimeout time.Duration
 	PollingInterval    time.Duration
 	TTL                int
+	HTTPClient         *http.Client
 }
 
 // NewDefaultConfig returns a default configuration for the DNSProvider.
@@ -46,13 +50,16 @@ func NewDefaultConfig() *Config {
 		TTL:                env.GetOrDefaultInt(EnvTTL, minTTL),
 		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout),
 		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+		},
 	}
 }
 
 // DNSProvider implements the challenge.Provider interface.
 type DNSProvider struct {
 	config *Config
-	client *civogo.Client
+	client *internal.Client
 }
 
 // NewDNSProvider returns a DNSProvider instance configured for CIVO.
@@ -84,7 +91,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 	}
 
 	// Create a Civo client - DNS is region independent, we can use any region
-	client, err := civogo.NewClient(config.Token, "LON1")
+	client, err := internal.NewClient(internal.OAuthStaticAccessToken(config.HTTPClient, config.Token), "LON1")
 	if err != nil {
 		return nil, fmt.Errorf("civo: %w", err)
 	}
@@ -96,6 +103,8 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
+	ctx := context.Background()
+
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("civo: could not find zone for domain %q: %w", domain, err)
@@ -103,7 +112,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 
 	zone := dns01.UnFqdn(authZone)
 
-	dnsDomain, err := d.client.GetDNSDomain(zone)
+	domainID, err := d.getDomainIDByName(ctx, zone)
 	if err != nil {
 		return fmt.Errorf("civo: %w", err)
 	}
@@ -113,10 +122,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 		return fmt.Errorf("civo: %w", err)
 	}
 
-	_, err = d.client.CreateDNSRecord(dnsDomain.ID, &civogo.DNSRecordConfig{
+	_, err = d.client.CreateDNSRecord(ctx, domainID, internal.Record{
 		Name:  subDomain,
 		Value: info.Value,
-		Type:  civogo.DNSRecordTypeTXT,
+		Type:  "TXT",
 		TTL:   d.config.TTL,
 	})
 	if err != nil {
@@ -130,6 +139,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 	info := dns01.GetChallengeInfo(domain, keyAuth)
 
+	ctx := context.Background()
+
 	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 	if err != nil {
 		return fmt.Errorf("civo: could not find zone for domain %q: %w", domain, err)
@@ -137,12 +148,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 
 	zone := dns01.UnFqdn(authZone)
 
-	dnsDomain, err := d.client.GetDNSDomain(zone)
+	domainID, err := d.getDomainIDByName(ctx, zone)
 	if err != nil {
 		return fmt.Errorf("civo: %w", err)
 	}
 
-	dnsRecords, err := d.client.ListDNSRecords(dnsDomain.ID)
+	dnsRecords, err := d.client.ListDNSRecords(ctx, domainID)
 	if err != nil {
 		return fmt.Errorf("civo: %w", err)
 	}
@@ -152,7 +163,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		return fmt.Errorf("civo: %w", err)
 	}
 
-	var dnsRecord civogo.DNSRecord
+	var dnsRecord internal.Record
 	for _, entry := range dnsRecords {
 		if entry.Name == subDomain && entry.Value == info.Value {
 			dnsRecord = entry
@@ -160,7 +171,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 		}
 	}
 
-	_, err = d.client.DeleteDNSRecord(&dnsRecord)
+	err = d.client.DeleteDNSRecord(ctx, dnsRecord)
 	if err != nil {
 		return fmt.Errorf("civo: %w", err)
 	}
@@ -173,3 +184,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 	return d.config.PropagationTimeout, d.config.PollingInterval
 }
+
+func (d *DNSProvider) getDomainIDByName(ctx context.Context, domain string) (string, error) {
+	domains, err := d.client.ListDomains(ctx)
+	if err != nil {
+		return "", fmt.Errorf("list domains: %w", err)
+	}
+
+	for _, d := range domains {
+		if d.Name == domain {
+			return d.ID, nil
+		}
+	}
+
+	return "", fmt.Errorf("domain %q not found", domain)
+}
diff --git a/providers/dns/civo/internal/client.go b/providers/dns/civo/internal/client.go
new file mode 100644
index 00000000..25a11ef6
--- /dev/null
+++ b/providers/dns/civo/internal/client.go
@@ -0,0 +1,212 @@
+/*
+Package internal Civo API client.
+
+Because the dependencies on k8s, the official client cannot be used.
+- https://github.com/civo/civogo/blob/v0.2.99/go.mod -> k8s.io/client-go
+- https://github.com/civo/civogo/blob/v0.3.34/go.mod -> k8s.io/api
+- https://github.com/civo/civogo/blob/v0.3.38/go.mod -> k8s.io/api + k8s.io/apimachinery
+- Current version -> https://github.com/civo/civogo/blob/v0.6.1/go.mod
+*/
+package internal
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+	"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
+	"golang.org/x/oauth2"
+)
+
+const defaultBaseURL = "https://api.civo.com/v2"
+
+// Client the Civo API client.
+type Client struct {
+	region string
+
+	BaseURL    *url.URL
+	HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(hc *http.Client, region string) (*Client, error) {
+	baseURL, _ := url.Parse(defaultBaseURL)
+
+	if hc == nil {
+		hc = &http.Client{Timeout: 10 * time.Second}
+	}
+
+	return &Client{
+		region:     region,
+		BaseURL:    baseURL,
+		HTTPClient: hc,
+	}, nil
+}
+
+// ListDomains a list of all domain names within the account.
+// https://www.civo.com/api/dns#list-domain-names
+func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
+	endpoint := c.BaseURL.JoinPath("dns")
+
+	req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var result []Domain
+
+	err = c.do(req, &result)
+	if err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+// ListDNSRecords a list of all DNS records in the specified domain.
+// https://www.civo.com/api/dns#list-dns-records
+func (c *Client) ListDNSRecords(ctx context.Context, domainID string) ([]Record, error) {
+	endpoint := c.BaseURL.JoinPath("dns", domainID, "records")
+
+	req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var result []Record
+
+	err = c.do(req, &result)
+	if err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+// CreateDNSRecord creates DNS records for a specific domain.
+// https://www.civo.com/api/dns#create-a-new-dns-record
+func (c *Client) CreateDNSRecord(ctx context.Context, domainID string, record Record) (*Record, error) {
+	endpoint := c.BaseURL.JoinPath("dns", domainID, "records")
+
+	req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record)
+	if err != nil {
+		return nil, err
+	}
+
+	var result Record
+
+	err = c.do(req, &result)
+	if err != nil {
+		return nil, err
+	}
+
+	return &result, nil
+}
+
+// DeleteDNSRecord remove a DNS record from a domain.
+// https://www.civo.com/api/dns#deleting-a-dns-record
+func (c *Client) DeleteDNSRecord(ctx context.Context, record Record) error {
+	endpoint := c.BaseURL.JoinPath("dns", record.DomainID, "records", record.ID)
+
+	req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return errutils.NewHTTPDoError(req, err)
+	}
+
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode/100 != 2 {
+		return parseError(req, resp)
+	}
+
+	if result == nil {
+		return nil
+	}
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return errutils.NewReadResponseError(req, resp.StatusCode, err)
+	}
+
+	err = json.Unmarshal(raw, result)
+	if err != nil {
+		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+	}
+
+	return nil
+}
+
+func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if payload != nil {
+		err := json.NewEncoder(buf).Encode(payload)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+		}
+	}
+
+	if method == http.MethodGet || method == http.MethodDelete {
+		query := endpoint.Query()
+		query.Set("region", c.region)
+
+		endpoint.RawQuery = query.Encode()
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create request: %w", err)
+	}
+
+	req.Header.Set("Accept", "application/json")
+
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	useragent.SetHeader(req.Header)
+
+	return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+	raw, _ := io.ReadAll(resp.Body)
+
+	var errAPI APIError
+	err := json.Unmarshal(raw, &errAPI)
+	if err != nil {
+		return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+	}
+
+	return &errAPI
+}
+
+// OAuthStaticAccessToken Authorization header.
+// https://www.civo.com/api#authentication
+func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
+	if client == nil {
+		client = &http.Client{Timeout: 5 * time.Second}
+	}
+
+	client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
+		Base:   client.Transport,
+	}
+
+	return client
+}
diff --git a/providers/dns/civo/internal/fixtures/create_dns_record-request.json b/providers/dns/civo/internal/fixtures/create_dns_record-request.json
new file mode 100644
index 00000000..ec881e14
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/create_dns_record-request.json
@@ -0,0 +1,6 @@
+{
+  "type": "TXT",
+  "name": "_acme-challenge",
+  "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+  "ttl": 600
+}
diff --git a/providers/dns/civo/internal/fixtures/create_dns_record.json b/providers/dns/civo/internal/fixtures/create_dns_record.json
new file mode 100644
index 00000000..d9557cf2
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/create_dns_record.json
@@ -0,0 +1,11 @@
+{
+  "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+  "created_at": "2019-04-11T12:47:56.000+01:00",
+  "updated_at": "2019-04-11T12:47:56.000+01:00",
+  "account_id": null,
+  "domain_id": "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+  "name": "_acme-challenge",
+  "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+  "type": "txt",
+  "ttl": 600
+}
diff --git a/providers/dns/civo/internal/fixtures/delete_dns_record.json b/providers/dns/civo/internal/fixtures/delete_dns_record.json
new file mode 100644
index 00000000..80bf76ad
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/delete_dns_record.json
@@ -0,0 +1,3 @@
+{
+  "result": "success"
+}
diff --git a/providers/dns/civo/internal/fixtures/error.json b/providers/dns/civo/internal/fixtures/error.json
new file mode 100644
index 00000000..0a55e079
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/error.json
@@ -0,0 +1,4 @@
+{
+  "code": "database_account_not_found",
+  "reason": "Failed to find the account within the internal database"
+}
diff --git a/providers/dns/civo/internal/fixtures/list_dns_records.json b/providers/dns/civo/internal/fixtures/list_dns_records.json
new file mode 100644
index 00000000..0c4e5473
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/list_dns_records.json
@@ -0,0 +1,13 @@
+[
+  {
+    "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+    "created_at": "2019-04-11T12:47:56.000+01:00",
+    "updated_at": "2019-04-11T12:47:56.000+01:00",
+    "account_id": null,
+    "domain_id": "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+    "name": "_acme-challenge",
+    "value": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+    "type": "txt",
+    "ttl": 600
+  }
+]
diff --git a/providers/dns/civo/internal/fixtures/list_domain_names.json b/providers/dns/civo/internal/fixtures/list_domain_names.json
new file mode 100644
index 00000000..909cdca0
--- /dev/null
+++ b/providers/dns/civo/internal/fixtures/list_domain_names.json
@@ -0,0 +1,7 @@
+[
+  {
+    "id": "7088fcea-7658-43e6-97fa-273f901978fd",
+    "account_id": "e7e8386e-434e-482f-95e0-c406e5d564c2",
+    "name": "example.com"
+  }
+]
diff --git a/providers/dns/civo/internal/types.go b/providers/dns/civo/internal/types.go
new file mode 100644
index 00000000..d173e2fc
--- /dev/null
+++ b/providers/dns/civo/internal/types.go
@@ -0,0 +1,28 @@
+package internal
+
+import "fmt"
+
+type APIError struct {
+	Code   string `json:"code"`
+	Reason string `json:"reason"`
+}
+
+func (a *APIError) Error() string {
+	return fmt.Sprintf("%s: %s", a.Code, a.Reason)
+}
+
+type Record struct {
+	ID        string `json:"id,omitempty"`
+	AccountID string `json:"account_id,omitempty"`
+	DomainID  string `json:"domain_id,omitempty"`
+	Name      string `json:"name,omitempty"`
+	Value     string `json:"value,omitempty"`
+	Type      string `json:"type,omitempty"`
+	TTL       int    `json:"ttl,omitempty"`
+}
+
+type Domain struct {
+	ID        string `json:"id,omitempty"`
+	AccountID string `json:"account_id,omitempty"`
+	Name      string `json:"name,omitempty"`
+}


Test patch:
diff --git a/providers/dns/civo/civo_test.go b/providers/dns/civo/civo_test.go
index 333cf0b1..8d08a17c 100644
--- a/providers/dns/civo/civo_test.go
+++ b/providers/dns/civo/civo_test.go
@@ -2,10 +2,14 @@ package civo
 
 import (
 	"fmt"
+	"net/http/httptest"
+	"net/url"
+	"path/filepath"
 	"testing"
 	"time"
 
 	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/go-acme/lego/v4/platform/tester/servermock"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -124,3 +128,70 @@ func TestLiveCleanUp(t *testing.T) {
 	err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
 	require.NoError(t, err)
 }
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+	return servermock.NewBuilder(
+		func(server *httptest.Server) (*DNSProvider, error) {
+			config := NewDefaultConfig()
+			config.Token = "secret"
+
+			p, err := NewDNSProviderConfig(config)
+			if err != nil {
+				return nil, err
+			}
+
+			p.client.BaseURL, _ = url.Parse(server.URL)
+
+			return p, nil
+		},
+		servermock.CheckHeader().
+			WithJSONHeaders().
+			With("Authorization", "Bearer secret").
+			WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`),
+	)
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+	provider := mockBuilder().
+		// https://www.civo.com/api/dns#list-domain-names
+		Route("GET /dns",
+			responseFromFixture("list_domain_names.json"),
+			servermock.CheckQueryParameter().Strict().
+				With("region", "LON1")).
+		// https://www.civo.com/api/dns#create-a-new-dns-record
+		Route("POST /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+			responseFromFixture("create_dns_record.json"),
+			servermock.CheckRequestJSONBodyFromFile("create_dns_record-request.json").
+				WithDirectory(filepath.Join("internal", "fixtures"))).
+		Build(t)
+
+	err := provider.Present("example.com", "abd", "123d==")
+	require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+	provider := mockBuilder().
+		// https://www.civo.com/api/dns#list-domain-names
+		Route("GET /dns",
+			responseFromFixture("list_domain_names.json"),
+			servermock.CheckQueryParameter().
+				With("region", "LON1")).
+		// https://www.civo.com/api/dns#list-dns-records
+		Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+			responseFromFixture("list_dns_records.json"),
+			servermock.CheckQueryParameter().Strict().
+				With("region", "LON1")).
+		// https://www.civo.com/api/dns#deleting-a-dns-record
+		Route("DELETE /dns/edc5dacf-a2ad-4757-41ee-c12f06259c70/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+			responseFromFixture("delete_dns_record.json"),
+			servermock.CheckQueryParameter().Strict().
+				With("region", "LON1")).
+		Build(t)
+
+	err := provider.CleanUp("example.com", "abd", "123d==")
+	require.NoError(t, err)
+}
+
+func responseFromFixture(filename string) *servermock.ResponseFromFileHandler {
+	return servermock.ResponseFromFile(filepath.Join("internal", "fixtures", filename))
+}
diff --git a/providers/dns/civo/internal/client_test.go b/providers/dns/civo/internal/client_test.go
new file mode 100644
index 00000000..42b4ad71
--- /dev/null
+++ b/providers/dns/civo/internal/client_test.go
@@ -0,0 +1,155 @@
+package internal
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/go-acme/lego/v4/platform/tester/servermock"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func mockBuilder() *servermock.Builder[*Client] {
+	return servermock.NewBuilder[*Client](
+		func(server *httptest.Server) (*Client, error) {
+			client, err := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "LON1")
+			if err != nil {
+				return nil, err
+			}
+
+			client.BaseURL, _ = url.Parse(server.URL)
+			client.HTTPClient = server.Client()
+
+			return client, nil
+		},
+		servermock.CheckHeader().
+			WithJSONHeaders().
+			With("Authorization", "Bearer secret").
+			WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`),
+	)
+}
+
+func TestClient_ListDomains(t *testing.T) {
+	client := mockBuilder().
+		Route("GET /dns",
+			servermock.ResponseFromFixture("list_domain_names.json"),
+			servermock.CheckQueryParameter().Strict().
+				With("region", "LON1")).
+		Build(t)
+
+	domains, err := client.ListDomains(t.Context())
+	require.NoError(t, err)
+
+	expected := []Domain{{
+		ID:        "7088fcea-7658-43e6-97fa-273f901978fd",
+		AccountID: "e7e8386e-434e-482f-95e0-c406e5d564c2",
+		Name:      "example.com",
+	}}
+
+	assert.Equal(t, expected, domains)
+}
+
+func TestClient_ListDNSRecords(t *testing.T) {
+	client := mockBuilder().
+		Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+			servermock.ResponseFromFixture("list_dns_records.json"),
+			servermock.CheckQueryParameter().Strict().
+				With("region", "LON1")).
+		Build(t)
+
+	records, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd")
+	require.NoError(t, err)
+
+	expected := []Record{
+		{
+			ID:       "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+			DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+			Name:     "_acme-challenge",
+			Value:    "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+			Type:     "txt",
+			TTL:      600,
+		},
+	}
+
+	assert.Equal(t, expected, records)
+}
+
+func TestClient_ListDNSRecords_error(t *testing.T) {
+	client := mockBuilder().
+		Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+			servermock.ResponseFromFixture("error.json").
+				WithStatusCode(http.StatusUnauthorized)).
+		Build(t)
+
+	_, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd")
+	require.EqualError(t, err, "database_account_not_found: Failed to find the account within the internal database")
+}
+
+func TestClient_ListDNSRecords_error_raw(t *testing.T) {
+	// the API says:
+	// > 4xx/5xx status may not be JSON, unless it's obvious that the response should be parsed for a specific reason.
+	// > So, for example, 404 Not Found pages are a standard page of text
+	// > but 403 Unauthorized requests may have a reason attribute available in the JSON object.
+	// https://www.civo.com/api#parameters-and-responses
+
+	client := mockBuilder().
+		Route("GET /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+			servermock.RawStringResponse(http.StatusText(http.StatusNotFound)).
+				WithStatusCode(http.StatusNotFound)).
+		Build(t)
+
+	_, err := client.ListDNSRecords(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd")
+	require.EqualError(t, err, "unexpected status code: [status code: 404] body: Not Found")
+}
+
+func TestClient_CreateDNSRecord(t *testing.T) {
+	client := mockBuilder().
+		Route("POST /dns/7088fcea-7658-43e6-97fa-273f901978fd/records",
+			servermock.ResponseFromFixture("create_dns_record.json"),
+			servermock.CheckRequestJSONBodyFromFile("create_dns_record-request.json")).
+		Build(t)
+
+	record := Record{
+		Name:  "_acme-challenge",
+		Value: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+		Type:  "TXT",
+		TTL:   600,
+	}
+
+	newRecord, err := client.CreateDNSRecord(t.Context(), "7088fcea-7658-43e6-97fa-273f901978fd", record)
+	require.NoError(t, err)
+
+	expected := &Record{
+		ID:       "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+		DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+		Name:     "_acme-challenge",
+		Value:    "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+		Type:     "txt",
+		TTL:      600,
+	}
+
+	assert.Equal(t, expected, newRecord)
+}
+
+func TestClient_DeleteDNSRecord(t *testing.T) {
+	client := mockBuilder().
+		Route("DELETE /dns/edc5dacf-a2ad-4757-41ee-c12f06259c70/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+			servermock.ResponseFromFixture("delete_dns_record.json"),
+			servermock.CheckQueryParameter().Strict().
+				With("region", "LON1")).
+		Build(t)
+
+	record := Record{
+		ID:       "76cc107f-fbef-4e2b-b97f-f5d34f4075d3",
+		DomainID: "edc5dacf-a2ad-4757-41ee-c12f06259c70",
+		Name:     "_acme-challenge",
+		Value:    "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY",
+		Type:     "TXT",
+		TTL:      600,
+	}
+
+	err := client.DeleteDNSRecord(t.Context(), record)
+	require.NoError(t, err)
+}