Repository: favonia/cloudflare-ddns
Pull request #63 (2021-08-07 00:58:20)

test(api): CloudflareHandle

codecov[bot]: # [Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
> Merging [#63](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (ca6cd1c) into [main](https://codecov.io/gh/favonia/cloudflare-ddns/commit/befb0a92b9f1578c27112902eb61ff5d93499a13?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (befb0a9) will **increase** coverage by `3.24%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63/graphs/tree.svg?width=650&height=150&src=pr&token=90Cwhy7OkI&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)

```diff
@@            Coverage Diff             @@
##             main      #63      +/-   ##
==========================================
+ Coverage   52.34%   55.59%   +3.24%     
==========================================
  Files           7        7              
  Lines         277      277              
==========================================
+ Hits          145      154       +9     
+ Misses        132      121      -11     
- Partials        0        2       +2     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `55.59% <ø> (+3.24%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [internal/api/cloudflare.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvYXBpL2Nsb3VkZmxhcmUuZ28=) | `22.64% <ø> (+5.66%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia). Last update [befb0a9...ca6cd1c](https://codecov.io/gh/favonia/cloudflare-ddns/pull/63?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).


Patch:
diff --git a/internal/api/cloudflare.go b/internal/api/cloudflare.go
index e9b80a9..5696c81 100644
--- a/internal/api/cloudflare.go
+++ b/internal/api/cloudflare.go
@@ -74,8 +74,14 @@ func (h *CloudflareHandle) FlushCache() {
 	h.cache.zoneOfDomain.Flush()
 }
 
-// ActiveZones replaces the broken built-in ZoneIDByName due to the possibility of multiple zones.
+// ActiveZones lists all active zones of the given name.
 func (h *CloudflareHandle) ActiveZones(ctx context.Context, indent pp.Indent, name string) ([]string, bool) {
+	// WithZoneFilters does not work with the empty zone name,
+	// and the owner of the DNS root zone will not be managed by Cloudflare anyways!
+	if name == "" {
+		return []string{}, true
+	}
+
 	if ids, found := h.cache.activeZones.Get(name); found {
 		return ids.([]string), true
 	}
@@ -114,9 +120,7 @@ zoneSearch:
 			continue zoneSearch
 		case 1: // len(zones) == 1
 			h.cache.zoneOfDomain.SetDefault(domain.ToASCII(), zones[0])
-
 			return zones[0], true
-
 		default: // len(zones) > 1
 			pp.Printf(indent, pp.EmojiImpossible,
 				"Found multiple active zones named %q. Specifying CF_ACCOUNT_ID might help.", zoneName)


Test patch:
diff --git a/internal/api/cloudflare_test.go b/internal/api/cloudflare_test.go
index 15c5771..68d5af6 100644
--- a/internal/api/cloudflare_test.go
+++ b/internal/api/cloudflare_test.go
@@ -4,56 +4,47 @@ import (
 	"context"
 	"crypto/sha512"
 	"encoding/hex"
+	"encoding/json"
 	"fmt"
+	"net"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 	"time"
 
+	"github.com/cloudflare/cloudflare-go"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 )
 
 // mockID returns a hex string of length 32, suitable for all kinds of IDs
 // used in the Cloudflare API.
-func mockID(seed string) string {
+func mockID(seed string, suffix int) string {
+	seed = fmt.Sprintf("%s/%d", seed, suffix)
 	arr := sha512.Sum512([]byte(seed))
 	return hex.EncodeToString(arr[:16])
 }
 
+func mockIDs(seed string, suffixes ...int) []string {
+	ids := make([]string, len(suffixes))
+	for i, suffix := range suffixes {
+		ids[i] = mockID(seed, suffix)
+	}
+	return ids
+}
+
 const (
 	mockToken   = "token123"
 	mockAccount = "account456"
 )
 
-func TestCloudflareAuthNewValid(t *testing.T) {
-	t.Parallel()
-
+func newServerAuth() (*httptest.Server, *http.ServeMux, *api.CloudflareAuth) {
 	mux := http.NewServeMux()
 	ts := httptest.NewServer(mux)
-	defer ts.Close()
-
-	mux.HandleFunc("/user/tokens/verify", func(w http.ResponseWriter, r *http.Request) {
-		assert.Equal(t, http.MethodGet, r.Method)
-		assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
-
-		w.Header().Set("content-type", "application/json")
-		fmt.Fprintf(w,
-			`{
-			  "result": { "id": "%s", "status": "active" },
-			  "success": true,
-			  "errors": [],
-			  "messages": [
-			    {
-			      "code": 10000,
-						"message": "This API Token is valid and active",
-			      "type": null
-			    }
-			  ]
-			}`, mockID("result"))
-	})
 
 	auth := api.CloudflareAuth{
 		Token:     mockToken,
@@ -61,58 +52,734 @@ func TestCloudflareAuthNewValid(t *testing.T) {
 		BaseURL:   ts.URL,
 	}
 
+	return ts, mux, &auth
+}
+
+func handleTokensVerify(t *testing.T, w http.ResponseWriter, r *http.Request) {
+	t.Helper()
+
+	assert.Equal(t, http.MethodGet, r.Method)
+	assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
+	assert.Empty(t, r.URL.Query())
+
+	w.Header().Set("content-type", "application/json")
+	fmt.Fprintf(w,
+		`{
+				"result": { "id": "%s", "status": "active" },
+				"success": true,
+				"errors": [],
+				"messages": [
+					{
+						"code": 10000,
+						"message": "This API Token is valid and active",
+						"type": null
+					}
+				]
+			}`,
+		mockID("result", 0))
+}
+
+func newHandle(t *testing.T) (*httptest.Server, *http.ServeMux, api.Handle) {
+	t.Helper()
+
+	ts, mux, auth := newServerAuth()
+
+	mux.HandleFunc("/user/tokens/verify", func(w http.ResponseWriter, r *http.Request) {
+		handleTokensVerify(t, w, r)
+	})
+
 	h, ok := auth.New(context.Background(), 3, time.Second)
-	require.NotNil(t, h)
 	require.True(t, ok)
+	require.NotNil(t, h)
+
+	return ts, mux, h
 }
 
-func TestCloudflareAuthNewEmpty(t *testing.T) {
+func TestNewValid(t *testing.T) {
 	t.Parallel()
 
-	mux := http.NewServeMux()
-	ts := httptest.NewServer(mux)
+	ts, _, _ := newHandle(t)
 	defer ts.Close()
+}
 
-	auth := api.CloudflareAuth{
-		Token:     "",
-		AccountID: mockAccount,
-		BaseURL:   ts.URL,
-	}
+func TestNewEmpty(t *testing.T) {
+	t.Parallel()
+
+	ts, _, auth := newServerAuth()
+	defer ts.Close()
 
+	auth.Token = ""
 	h, ok := auth.New(context.Background(), 3, time.Second)
-	require.Nil(t, h)
 	require.False(t, ok)
+	require.Nil(t, h)
 }
 
-func TestCloudflareAuthNewInvalid(t *testing.T) {
+func TestNewInvalid(t *testing.T) {
 	t.Parallel()
 
-	mux := http.NewServeMux()
-	ts := httptest.NewServer(mux)
+	ts, mux, auth := newServerAuth()
 	defer ts.Close()
 
 	mux.HandleFunc("/user/tokens/verify", func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, http.MethodGet, r.Method)
 		assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
+		assert.Empty(t, r.URL.Query())
 
 		w.WriteHeader(http.StatusUnauthorized)
 		w.Header().Set("content-type", "application/json")
 		fmt.Fprintf(w,
 			`{
-			  "success": false,
-			  "errors": [{ "code": 1000, "message": "Invalid API Token" }],
-			  "messages": [],
-			  "result": null
+				"success": false,
+				"errors": [{ "code": 1000, "message": "Invalid API Token" }],
+				"messages": [],
+				"result": null
 			}`)
 	})
 
-	auth := api.CloudflareAuth{
-		Token:     mockToken,
-		AccountID: mockAccount,
-		BaseURL:   ts.URL,
-	}
-
 	h, ok := auth.New(context.Background(), 3, time.Second)
+	require.False(t, ok)
 	require.Nil(t, h)
+}
+
+func mockZone(zoneName string, i int) *cloudflare.Zone {
+	return &cloudflare.Zone{ //nolint:exhaustivestruct
+		ID:     mockID(zoneName, i),
+		Name:   zoneName,
+		Status: "active",
+	}
+}
+
+func mockZonesResponse(zoneName string, numZones int) *cloudflare.ZonesResponse {
+	if numZones > 50 {
+		panic("mockZonesResponse got too many zone names.")
+	}
+
+	zones := make([]cloudflare.Zone, numZones)
+	for i := 0; i < numZones; i++ {
+		zones[i] = *mockZone(zoneName, i)
+	}
+
+	return &cloudflare.ZonesResponse{
+		Result: zones,
+		ResultInfo: cloudflare.ResultInfo{
+			Page:       1,
+			PerPage:    50,
+			TotalPages: (numZones + 49) / 50,
+			Count:      numZones,
+			Total:      numZones,
+			Cursor:     "",
+			Cursors:    cloudflare.ResultInfoCursors{}, //nolint:exhaustivestruct
+		},
+		Response: cloudflare.Response{
+			Success:  true,
+			Errors:   []cloudflare.ResponseInfo{},
+			Messages: []cloudflare.ResponseInfo{},
+		},
+	}
+}
+
+func handleZones(t *testing.T, zoneName string, numZones int, w http.ResponseWriter, r *http.Request) {
+	t.Helper()
+
+	assert.Equal(t, http.MethodGet, r.Method)
+	assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
+	assert.Equal(t, url.Values{
+		"account.id": {mockAccount},
+		"name":       {zoneName},
+		"per_page":   {"50"},
+		"status":     {"active"},
+	}, r.URL.Query())
+
+	w.Header().Set("content-type", "application/json")
+	err := json.NewEncoder(w).Encode(mockZonesResponse(zoneName, numZones))
+	assert.NoError(t, err)
+}
+
+type zonesHandler struct {
+	mux         *http.ServeMux
+	numZones    *map[string]int
+	accessCount *int
+}
+
+func newZonesHandler(t *testing.T, mux *http.ServeMux) *zonesHandler {
+	t.Helper()
+
+	var (
+		numZones    map[string]int
+		accessCount int
+	)
+
+	mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) {
+		if accessCount <= 0 {
+			return
+		}
+		accessCount--
+
+		zoneName := r.URL.Query().Get("name")
+		handleZones(t, zoneName, numZones[zoneName], w, r)
+	})
+
+	return &zonesHandler{
+		mux:         mux,
+		numZones:    &numZones,
+		accessCount: &accessCount,
+	}
+}
+
+func (h *zonesHandler) set(numZones map[string]int, accessCount int) {
+	*(h.numZones), *(h.accessCount) = numZones, accessCount
+}
+
+func (h *zonesHandler) isExhausted() bool {
+	return *h.accessCount == 0
+}
+
+func TestActiveZonesRoot(t *testing.T) {
+	t.Parallel()
+
+	ts, _, h := newHandle(t)
+	defer ts.Close()
+
+	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "")
+	require.True(t, ok)
+	require.Empty(t, zones)
+}
+
+func TestActiveZonesTwo(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+
+	zh.set(map[string]int{"test.org": 2}, 1)
+	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	require.True(t, ok)
+	require.Equal(t, mockIDs("test.org", 0, 1), zones)
+	require.True(t, zh.isExhausted())
+
+	zh.set(nil, 0)
+	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	require.True(t, ok)
+	require.Equal(t, mockIDs("test.org", 0, 1), zones)
+	require.True(t, zh.isExhausted())
+
+	h.FlushCache()
+
+	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	require.False(t, ok)
+	require.Nil(t, zones)
+	require.True(t, zh.isExhausted())
+}
+
+func TestActiveZonesEmpty(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+
+	zh.set(map[string]int{}, 1)
+	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	require.True(t, ok)
+	require.Empty(t, zones)
+	require.True(t, zh.isExhausted())
+
+	zh.set(nil, 0) // this should not affect the result due to the caching
+	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	require.True(t, ok)
+	require.Empty(t, zones)
+	require.True(t, zh.isExhausted())
+
+	h.FlushCache()
+
+	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	require.False(t, ok)
+	require.Nil(t, zones)
+	require.True(t, zh.isExhausted())
+}
+
+func TestZoneOfDomainRoot(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+
+	zh.set(map[string]int{"test.org": 1}, 1)
+	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "test.org")
+	require.True(t, ok)
+	require.Equal(t, mockID("test.org", 0), zoneID)
+	require.True(t, zh.isExhausted())
+
+	zh.set(nil, 0)
+	zoneID, ok = h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "test.org")
+	require.True(t, ok)
+	require.Equal(t, mockID("test.org", 0), zoneID)
+}
+
+func TestZoneOfDomainOneLevel(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+
+	zh.set(map[string]int{"test.org": 1}, 2)
+	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
+	require.True(t, ok)
+	require.Equal(t, mockID("test.org", 0), zoneID)
+	require.True(t, zh.isExhausted())
+
+	zh.set(nil, 0)
+	zoneID, ok = h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
+	require.True(t, ok)
+	require.Equal(t, mockID("test.org", 0), zoneID)
+}
+
+func TestZoneOfDomainNone(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+
+	zh.set(map[string]int{}, 3)
+	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
+	require.False(t, ok)
+	require.Equal(t, "", zoneID)
+	require.True(t, zh.isExhausted())
+}
+
+func TestZoneOfDomainMultiple(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+
+	zh.set(map[string]int{"test.org": 2}, 2)
+	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
+	require.False(t, ok)
+	require.Equal(t, "", zoneID)
+	require.True(t, zh.isExhausted())
+}
+
+func TestZoneOfDomainInvalid(t *testing.T) {
+	t.Parallel()
+
+	ts, _, h := newHandle(t)
+	defer ts.Close()
+
+	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
+	require.False(t, ok)
+	require.Equal(t, "", zoneID)
+}
+
+func mockDNSRecord(id string, ipNet ipnet.Type, domain api.FQDN, ip net.IP) *cloudflare.DNSRecord {
+	return &cloudflare.DNSRecord{ //nolint:exhaustivestruct
+		ID:      id,
+		Type:    ipNet.RecordType(),
+		Name:    domain.ToASCII(),
+		Content: ip.String(),
+	}
+}
+
+//nolint: unparam // domain always = "test.org" for now
+func mockDNSListResponse(ipNet ipnet.Type, domain api.FQDN, ips map[string]net.IP) *cloudflare.DNSListResponse {
+	if len(ips) > 100 {
+		panic("mockDNSResponse got too many IPs.")
+	}
+
+	rs := make([]cloudflare.DNSRecord, 0, len(ips))
+	for id, ip := range ips {
+		rs = append(rs, *mockDNSRecord(id, ipNet, domain, ip))
+	}
+
+	return &cloudflare.DNSListResponse{
+		Result: rs,
+		ResultInfo: cloudflare.ResultInfo{
+			Page:       1,
+			PerPage:    100,
+			TotalPages: (len(ips) + 99) / 100,
+			Count:      len(ips),
+			Total:      len(ips),
+			Cursor:     "",
+			Cursors:    cloudflare.ResultInfoCursors{}, //nolint:exhaustivestruct
+		},
+		Response: cloudflare.Response{
+			Success:  true,
+			Errors:   []cloudflare.ResponseInfo{},
+			Messages: []cloudflare.ResponseInfo{},
+		},
+	}
+}
+
+func TestListRecords(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+	zh.set(map[string]int{"test.org": 1}, 2)
+
+	var (
+		ipNet       ipnet.Type
+		ips         map[string]net.IP
+		accessCount int
+	)
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/dns_records", mockID("test.org", 0)),
+		func(w http.ResponseWriter, r *http.Request) {
+			if accessCount <= 0 {
+				return
+			}
+			accessCount--
+
+			assert.Equal(t, http.MethodGet, r.Method)
+			assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
+			assert.Equal(t, url.Values{
+				"name":     {"sub.test.org"},
+				"page":     {"1"},
+				"per_page": {"100"},
+				"type":     {ipNet.RecordType()},
+			}, r.URL.Query())
+
+			w.Header().Set("content-type", "application/json")
+			err := json.NewEncoder(w).Encode(mockDNSListResponse(ipNet, "test.org", ips))
+			assert.NoError(t, err)
+		})
+
+	expected := map[string]net.IP{"record1": net.ParseIP("::1"), "record2": net.ParseIP("::2")}
+	ipNet, ips, accessCount = ipnet.IP6, expected, 1
+	ips, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	require.True(t, ok)
+	require.Equal(t, expected, ips)
+	require.Equal(t, 0, accessCount)
+
+	// testing the caching
+	ips, ok = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	require.True(t, ok)
+	require.Equal(t, expected, ips)
+}
+
+func TestListRecordsInvalidDomain(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+	zh.set(map[string]int{"test.org": 1}, 2)
+
+	ips, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP4)
+	require.False(t, ok)
+	require.Nil(t, ips)
+
+	ips, ok = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	require.False(t, ok)
+	require.Nil(t, ips)
+}
+
+func TestListRecordsInvalidZone(t *testing.T) {
+	t.Parallel()
+
+	ts, _, h := newHandle(t)
+	defer ts.Close()
+
+	ips, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP4)
+	require.False(t, ok)
+	require.Nil(t, ips)
+
+	ips, ok = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	require.False(t, ok)
+	require.Nil(t, ips)
+}
+
+func envelopDNSRecordResponse(record *cloudflare.DNSRecord) *cloudflare.DNSRecordResponse {
+	return &cloudflare.DNSRecordResponse{
+		Result: *record,
+		ResultInfo: cloudflare.ResultInfo{
+			Page:       1,
+			PerPage:    100,
+			TotalPages: 1,
+			Count:      1,
+			Total:      1,
+			Cursor:     "",
+			Cursors:    cloudflare.ResultInfoCursors{}, //nolint:exhaustivestruct
+		},
+		Response: cloudflare.Response{
+			Success:  true,
+			Errors:   []cloudflare.ResponseInfo{},
+			Messages: []cloudflare.ResponseInfo{},
+		},
+	}
+}
+
+func mockDNSRecordResponse(id string, ipNet ipnet.Type, domain api.FQDN, ip net.IP) *cloudflare.DNSRecordResponse {
+	return envelopDNSRecordResponse(mockDNSRecord(id, ipNet, domain, ip))
+}
+
+func TestDeleteRecordsValid(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+	zh.set(map[string]int{"test.org": 1}, 2)
+
+	var (
+		listAccessCount   int
+		deleteAccessCount int
+	)
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/dns_records", mockID("test.org", 0)),
+		func(w http.ResponseWriter, r *http.Request) {
+			if listAccessCount <= 0 {
+				return
+			}
+			listAccessCount--
+
+			w.Header().Set("content-type", "application/json")
+			_ = json.NewEncoder(w).Encode(mockDNSListResponse(ipnet.IP6, "test.org",
+				map[string]net.IP{"record1": net.ParseIP("::1")}))
+		})
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/dns_records/record1", mockID("test.org", 0)),
+		func(w http.ResponseWriter, r *http.Request) {
+			if deleteAccessCount <= 0 {
+				return
+			}
+			deleteAccessCount--
+
+			assert.Equal(t, http.MethodDelete, r.Method)
+			assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
+			assert.Empty(t, r.URL.Query())
+
+			w.Header().Set("content-type", "application/json")
+			err := json.NewEncoder(w).Encode(mockDNSRecordResponse("record1", ipnet.IP6, "test.org", net.ParseIP("::1")))
+			assert.NoError(t, err)
+		})
+
+	deleteAccessCount = 1
+	ok := h.DeleteRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1")
+	require.True(t, ok)
+
+	listAccessCount, deleteAccessCount = 1, 1
+	_, _ = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	_ = h.DeleteRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1")
+	rs, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	require.True(t, ok)
+	require.Empty(t, rs)
+}
+
+func TestDeleteRecordsInvalid(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+	zh.set(map[string]int{"test.org": 1}, 2)
+
+	ok := h.DeleteRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1")
+	require.False(t, ok)
+}
+
+func TestDeleteRecordsZoneInvalid(t *testing.T) {
+	t.Parallel()
+
+	ts, _, h := newHandle(t)
+	defer ts.Close()
+
+	ok := h.DeleteRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1")
+	require.False(t, ok)
+}
+
+//nolint: funlen
+func TestUpdateRecordValid(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+	zh.set(map[string]int{"test.org": 1}, 2)
+
+	var (
+		listAccessCount   int
+		updateAccessCount int
+	)
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/dns_records", mockID("test.org", 0)),
+		func(w http.ResponseWriter, r *http.Request) {
+			assert.Equal(t, http.MethodGet, r.Method)
+			if listAccessCount <= 0 {
+				return
+			}
+			listAccessCount--
+
+			w.Header().Set("content-type", "application/json")
+			_ = json.NewEncoder(w).Encode(mockDNSListResponse(ipnet.IP6, "test.org",
+				map[string]net.IP{"record1": net.ParseIP("::1")}))
+		})
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/dns_records/record1", mockID("test.org", 0)),
+		func(w http.ResponseWriter, r *http.Request) {
+			assert.Equal(t, http.MethodPatch, r.Method)
+			if updateAccessCount <= 0 {
+				return
+			}
+			updateAccessCount--
+
+			assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
+			assert.Empty(t, r.URL.Query())
+
+			var record cloudflare.DNSRecord
+			if err := json.NewDecoder(r.Body).Decode(&record); !assert.NoError(t, err) {
+				return
+			}
+
+			assert.Equal(t, "sub.test.org", record.Name)
+			assert.Equal(t, ipnet.IP6.RecordType(), record.Type)
+			assert.Equal(t, "::2", record.Content)
+
+			w.Header().Set("content-type", "application/json")
+			err := json.NewEncoder(w).Encode(mockDNSRecordResponse("record1", ipnet.IP6, "sub.test.org", net.ParseIP("::2")))
+			assert.NoError(t, err)
+		})
+
+	updateAccessCount = 1
+	ok := h.UpdateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::2"))
+	require.True(t, ok)
+
+	listAccessCount, updateAccessCount = 1, 1
+	_, _ = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	_ = h.UpdateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::2"))
+	rs, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	require.True(t, ok)
+	require.Equal(t, map[string]net.IP{"record1": net.ParseIP("::2")}, rs)
+}
+
+func TestUpdateRecordInvalid(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+	zh.set(map[string]int{"test.org": 1}, 2)
+
+	ok := h.UpdateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::1"))
+	require.False(t, ok)
+}
+
+func TestUpdateRecordInvalidZone(t *testing.T) {
+	t.Parallel()
+
+	ts, _, h := newHandle(t)
+	defer ts.Close()
+
+	ok := h.UpdateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::1"))
+	require.False(t, ok)
+}
+
+//nolint: funlen
+func TestCreateRecordValid(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+	zh.set(map[string]int{"test.org": 1}, 2)
+
+	var (
+		listAccessCount   int
+		createAccessCount int
+	)
+
+	mux.HandleFunc(fmt.Sprintf("/zones/%s/dns_records", mockID("test.org", 0)),
+		func(w http.ResponseWriter, r *http.Request) {
+			switch r.Method {
+			case http.MethodGet:
+				if listAccessCount <= 0 {
+					return
+				}
+				listAccessCount--
+
+				w.Header().Set("content-type", "application/json")
+				_ = json.NewEncoder(w).Encode(mockDNSListResponse(ipnet.IP6, "test.org",
+					map[string]net.IP{"record1": net.ParseIP("::1")}))
+			case http.MethodPost:
+				if createAccessCount <= 0 {
+					return
+				}
+				createAccessCount--
+
+				assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
+				assert.Empty(t, r.URL.Query())
+
+				var record cloudflare.DNSRecord
+				if err := json.NewDecoder(r.Body).Decode(&record); !assert.NoError(t, err) {
+					return
+				}
+
+				assert.Equal(t, "sub.test.org", record.Name)
+				assert.Equal(t, ipnet.IP6.RecordType(), record.Type)
+				assert.Equal(t, "::1", record.Content)
+				assert.Equal(t, 100, record.TTL)
+				assert.Equal(t, false, *record.Proxied)
+				record.ID = "record1"
+
+				w.Header().Set("content-type", "application/json")
+				err := json.NewEncoder(w).Encode(envelopDNSRecordResponse(&record))
+				assert.NoError(t, err)
+			}
+		})
+
+	createAccessCount = 1
+	actualID, ok := h.CreateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
+	require.True(t, ok)
+	require.Equal(t, "record1", actualID)
+
+	listAccessCount, createAccessCount = 1, 1
+	_, _ = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	_, _ = h.CreateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
+	rs, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	require.True(t, ok)
+	require.Equal(t, map[string]net.IP{"record1": net.ParseIP("::1")}, rs)
+}
+
+func TestCreateRecordInvalid(t *testing.T) {
+	t.Parallel()
+
+	ts, mux, h := newHandle(t)
+	defer ts.Close()
+
+	zh := newZonesHandler(t, mux)
+	zh.set(map[string]int{"test.org": 1}, 2)
+
+	actualID, ok := h.CreateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
+	require.False(t, ok)
+	require.Equal(t, "", actualID)
+}
+
+func TestCreateRecordInvalidZone(t *testing.T) {
+	t.Parallel()
+
+	ts, _, h := newHandle(t)
+	defer ts.Close()
+
+	actualID, ok := h.CreateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
 	require.False(t, ok)
+	require.Equal(t, "", actualID)
 }