external-dns/provider/cloudflare/cloudflare_test.go

3262 lines
93 KiB
Go

/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cloudflare
import (
"context"
"errors"
"fmt"
"os"
"slices"
"sort"
"strings"
"testing"
"github.com/cloudflare/cloudflare-go"
"github.com/cloudflare/cloudflare-go/v4/zones"
"github.com/maxatome/go-testdeep/td"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/internal/testutils"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
"sigs.k8s.io/external-dns/source/annotations"
)
// proxyEnabled and proxyDisabled are pointers to bool values used to set if a record should be proxied through Cloudflare.
var (
proxyEnabled *bool = testutils.ToPtr(true)
proxyDisabled *bool = testutils.ToPtr(false)
)
type MockAction struct {
Name string
ZoneId string
RecordId string
RecordData cloudflare.DNSRecord
RegionalHostname regionalHostname
}
type mockCloudFlareClient struct {
Zones map[string]string
Records map[string]map[string]cloudflare.DNSRecord
Actions []MockAction
listZonesError error // For v4 ListZones
getZoneError error // For v4 GetZone
dnsRecordsError error
customHostnames map[string][]cloudflare.CustomHostname
regionalHostnames map[string][]regionalHostname
}
var ExampleDomain = []cloudflare.DNSRecord{
{
ID: "1234567890",
Name: "foobar.bar.com",
Type: endpoint.RecordTypeA,
TTL: 120,
Content: "1.2.3.4",
Proxied: proxyDisabled,
Comment: "valid comment",
},
{
ID: "2345678901",
Name: "foobar.bar.com",
Type: endpoint.RecordTypeA,
TTL: 120,
Content: "3.4.5.6",
Proxied: proxyDisabled,
},
{
ID: "1231231233",
Name: "bar.foo.com",
Type: endpoint.RecordTypeA,
TTL: 1,
Content: "2.3.4.5",
Proxied: proxyDisabled,
},
}
func NewMockCloudFlareClient() *mockCloudFlareClient {
return &mockCloudFlareClient{
Zones: map[string]string{
"001": "bar.com",
"002": "foo.com",
},
Records: map[string]map[string]cloudflare.DNSRecord{
"001": {},
"002": {},
},
customHostnames: map[string][]cloudflare.CustomHostname{},
regionalHostnames: map[string][]regionalHostname{},
}
}
func NewMockCloudFlareClientWithRecords(records map[string][]cloudflare.DNSRecord) *mockCloudFlareClient {
m := NewMockCloudFlareClient()
for zoneID, zoneRecords := range records {
if zone, ok := m.Records[zoneID]; ok {
for _, record := range zoneRecords {
zone[record.ID] = record
}
}
}
return m
}
func getDNSRecordFromRecordParams(rp any) cloudflare.DNSRecord {
switch params := rp.(type) {
case cloudflare.CreateDNSRecordParams:
record := cloudflare.DNSRecord{
ID: params.ID,
Name: params.Name,
TTL: params.TTL,
Proxied: params.Proxied,
Type: params.Type,
Content: params.Content,
}
if params.Type == "MX" {
record.Priority = params.Priority
}
return record
case cloudflare.UpdateDNSRecordParams:
record := cloudflare.DNSRecord{
ID: params.ID,
Name: params.Name,
TTL: params.TTL,
Proxied: params.Proxied,
Type: params.Type,
Content: params.Content,
}
if params.Type == "MX" {
record.Priority = params.Priority
}
return record
default:
return cloudflare.DNSRecord{}
}
}
func generateDNSRecordID(rrtype string, name string, content string) string {
return fmt.Sprintf("%s-%s-%s", name, rrtype, content)
}
func (m *mockCloudFlareClient) CreateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) {
recordData := getDNSRecordFromRecordParams(rp)
if recordData.ID == "" {
recordData.ID = generateDNSRecordID(recordData.Type, recordData.Name, recordData.Content)
}
m.Actions = append(m.Actions, MockAction{
Name: "Create",
ZoneId: rc.Identifier,
RecordId: recordData.ID,
RecordData: recordData,
})
if zone, ok := m.Records[rc.Identifier]; ok {
zone[recordData.ID] = recordData
}
if recordData.Name == "newerror.bar.com" {
return cloudflare.DNSRecord{}, fmt.Errorf("failed to create record")
}
return cloudflare.DNSRecord{}, nil
}
func (m *mockCloudFlareClient) ListDNSRecords(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) {
if m.dnsRecordsError != nil {
return nil, &cloudflare.ResultInfo{}, m.dnsRecordsError
}
result := []cloudflare.DNSRecord{}
if zone, ok := m.Records[rc.Identifier]; ok {
for _, record := range zone {
if strings.HasPrefix(record.Name, "newerror-list-") {
m.DeleteDNSRecord(ctx, rc, record.ID)
return nil, &cloudflare.ResultInfo{}, errors.New("failed to list erroring DNS record")
}
result = append(result, record)
}
}
if len(result) == 0 || rp.PerPage == 0 {
return result, &cloudflare.ResultInfo{Page: 1, TotalPages: 1, Count: 0, Total: 0}, nil
}
// if not pagination options were passed in, return the result as is
if rp.Page == 0 {
return result, &cloudflare.ResultInfo{Page: 1, TotalPages: 1, Count: len(result), Total: len(result)}, nil
}
// otherwise, split the result into chunks of size rp.PerPage to simulate the pagination from the API
chunks := [][]cloudflare.DNSRecord{}
// to ensure consistency in the multiple calls to this function, sort the result slice
sort.Slice(result, func(i, j int) bool { return strings.Compare(result[i].ID, result[j].ID) > 0 })
for rp.PerPage < len(result) {
result, chunks = result[rp.PerPage:], append(chunks, result[0:rp.PerPage])
}
chunks = append(chunks, result)
// return the requested page
partialResult := chunks[rp.Page-1]
return partialResult, &cloudflare.ResultInfo{
PerPage: rp.PerPage,
Page: rp.Page,
TotalPages: len(chunks),
Count: len(partialResult),
Total: len(result),
}, nil
}
func (m *mockCloudFlareClient) UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error {
recordData := getDNSRecordFromRecordParams(rp)
m.Actions = append(m.Actions, MockAction{
Name: "Update",
ZoneId: rc.Identifier,
RecordId: rp.ID,
RecordData: recordData,
})
if zone, ok := m.Records[rc.Identifier]; ok {
if _, ok := zone[rp.ID]; ok {
if strings.HasPrefix(recordData.Name, "newerror-update-") {
return errors.New("failed to update erroring DNS record")
}
zone[rp.ID] = recordData
}
}
return nil
}
func (m *mockCloudFlareClient) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error {
m.Actions = append(m.Actions, MockAction{
Name: "Delete",
ZoneId: rc.Identifier,
RecordId: recordID,
})
if zone, ok := m.Records[rc.Identifier]; ok {
if _, ok := zone[recordID]; ok {
name := zone[recordID].Name
delete(zone, recordID)
if strings.HasPrefix(name, "newerror-delete-") {
return errors.New("failed to delete erroring DNS record")
}
return nil
}
}
return nil
}
func (m *mockCloudFlareClient) CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflare.CustomHostname) ([]cloudflare.CustomHostname, cloudflare.ResultInfo, error) {
var err error = nil
perPage := 50 // cloudflare-go v0 API hardcoded
if strings.HasPrefix(zoneID, "newerror-") {
return nil, cloudflare.ResultInfo{}, errors.New("failed to list custom hostnames")
}
if filter.Hostname != "" {
err = errors.New("filters are not supported for custom hostnames mock test")
return nil, cloudflare.ResultInfo{}, err
}
if page < 1 {
err = errors.New("incorrect page value for custom hostnames list")
return nil, cloudflare.ResultInfo{}, err
}
result := []cloudflare.CustomHostname{}
if chs, ok := m.customHostnames[zoneID]; ok {
for idx := (page - 1) * perPage; idx < min(len(chs), page*perPage); idx++ {
ch := m.customHostnames[zoneID][idx]
if strings.HasPrefix(ch.Hostname, "newerror-list-") {
m.DeleteCustomHostname(ctx, zoneID, ch.ID)
return nil, cloudflare.ResultInfo{}, errors.New("failed to list erroring custom hostname")
}
result = append(result, ch)
}
return result,
cloudflare.ResultInfo{
Page: page,
PerPage: perPage,
Count: len(result),
Total: len(chs),
TotalPages: len(chs)/page + 1,
}, err
} else {
return result,
cloudflare.ResultInfo{
Page: page,
PerPage: perPage,
Count: 0,
Total: 0,
TotalPages: 0,
}, err
}
}
func (m *mockCloudFlareClient) CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflare.CustomHostname) (*cloudflare.CustomHostnameResponse, error) {
if ch.Hostname == "" || ch.CustomOriginServer == "" || ch.Hostname == "newerror-create.foo.fancybar.com" {
return nil, fmt.Errorf("Invalid custom hostname or origin hostname")
}
if _, ok := m.customHostnames[zoneID]; !ok {
m.customHostnames[zoneID] = []cloudflare.CustomHostname{}
}
var newCustomHostname cloudflare.CustomHostname = ch
newCustomHostname.ID = fmt.Sprintf("ID-%s", ch.Hostname)
m.customHostnames[zoneID] = append(m.customHostnames[zoneID], newCustomHostname)
return &cloudflare.CustomHostnameResponse{}, nil
}
func (m *mockCloudFlareClient) DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error {
idx := 0
if idx = getCustomHostnameIdxByID(m.customHostnames[zoneID], customHostnameID); idx < 0 {
return fmt.Errorf("Invalid custom hostname ID to delete")
}
m.customHostnames[zoneID] = append(m.customHostnames[zoneID][:idx], m.customHostnames[zoneID][idx+1:]...)
if customHostnameID == "ID-newerror-delete.foo.fancybar.com" {
return fmt.Errorf("Invalid custom hostname to delete")
}
return nil
}
func (m *mockCloudFlareClient) ZoneIDByName(zoneName string) (string, error) {
// Simulate iterator error (line 144)
if m.listZonesError != nil {
return "", fmt.Errorf("failed to list zones from CloudFlare API: %w", m.listZonesError)
}
for id, name := range m.Zones {
if name == zoneName {
return id, nil
}
}
// Use the improved error message (line 147)
return "", fmt.Errorf("zone %q not found in CloudFlare account - verify the zone exists and API credentials have access to it", zoneName)
}
// V4 Zone methods
func (m *mockCloudFlareClient) ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] {
if m.listZonesError != nil {
return &mockAutoPager[zones.Zone]{
err: m.listZonesError,
}
}
var results []zones.Zone
for id, zoneName := range m.Zones {
results = append(results, zones.Zone{
ID: id,
Name: zoneName,
Plan: zones.ZonePlan{IsSubscribed: strings.HasSuffix(zoneName, "bar.com")}, //nolint:SA1019 // Plan.IsSubscribed is deprecated but no replacement available yet
})
}
return &mockAutoPager[zones.Zone]{
items: results,
}
}
func (m *mockCloudFlareClient) GetZone(ctx context.Context, zoneID string) (*zones.Zone, error) {
if m.getZoneError != nil {
return nil, m.getZoneError
}
for id, zoneName := range m.Zones {
if zoneID == id {
return &zones.Zone{
ID: zoneID,
Name: zoneName,
Plan: zones.ZonePlan{IsSubscribed: strings.HasSuffix(zoneName, "bar.com")}, //nolint:SA1019 // Plan.IsSubscribed is deprecated but no replacement available yet
}, nil
}
}
return nil, errors.New("Unknown zoneID: " + zoneID)
}
func getCustomHostnameIdxByID(chs []cloudflare.CustomHostname, customHostnameID string) int {
for idx, ch := range chs {
if ch.ID == customHostnameID {
return idx
}
}
return -1
}
func AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, managedRecords []string, args ...interface{}) {
t.Helper()
var client *mockCloudFlareClient
if provider.Client == nil {
client = NewMockCloudFlareClient()
provider.Client = client
} else {
client = provider.Client.(*mockCloudFlareClient)
}
ctx := context.Background()
records, err := provider.Records(ctx)
if err != nil {
t.Fatalf("cannot fetch records, %s", err)
}
endpoints, err = provider.AdjustEndpoints(endpoints)
assert.NoError(t, err)
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
plan := &plan.Plan{
Current: records,
Desired: endpoints,
DomainFilter: endpoint.MatchAllDomainFilters{domainFilter},
ManagedRecords: managedRecords,
}
changes := plan.Calculate().Changes
// Records other than A, CNAME and NS are not supported by planner, just create them
for _, endpoint := range endpoints {
if !slices.Contains(managedRecords, endpoint.RecordType) {
changes.Create = append(changes.Create, endpoint)
}
}
err = provider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Fatalf("cannot apply changes, %s", err)
}
td.Cmp(t, client.Actions, actions, args...)
}
func TestCloudflareA(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "A",
DNSName: "bar.com",
Targets: endpoint.Targets{"127.0.0.1", "127.0.0.2"},
},
}
AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
Type: "A",
Name: "bar.com",
Content: "127.0.0.1",
TTL: 1,
Proxied: proxyDisabled,
},
},
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.2"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("A", "bar.com", "127.0.0.2"),
Type: "A",
Name: "bar.com",
Content: "127.0.0.2",
TTL: 1,
Proxied: proxyDisabled,
},
},
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareCname(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "CNAME",
DNSName: "cname.bar.com",
Targets: endpoint.Targets{"google.com", "facebook.com"},
},
}
AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("CNAME", "cname.bar.com", "google.com"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("CNAME", "cname.bar.com", "google.com"),
Type: "CNAME",
Name: "cname.bar.com",
Content: "google.com",
TTL: 1,
Proxied: proxyDisabled,
},
},
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("CNAME", "cname.bar.com", "facebook.com"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("CNAME", "cname.bar.com", "facebook.com"),
Type: "CNAME",
Name: "cname.bar.com",
Content: "facebook.com",
TTL: 1,
Proxied: proxyDisabled,
},
},
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareMx(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "MX",
DNSName: "mx.bar.com",
Targets: endpoint.Targets{"10 google.com", "20 facebook.com"},
},
}
AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("MX", "mx.bar.com", "google.com"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("MX", "mx.bar.com", "google.com"),
Type: "MX",
Name: "mx.bar.com",
Content: "google.com",
Priority: cloudflare.Uint16Ptr(10),
TTL: 1,
Proxied: proxyDisabled,
},
},
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("MX", "mx.bar.com", "facebook.com"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("MX", "mx.bar.com", "facebook.com"),
Type: "MX",
Name: "mx.bar.com",
Content: "facebook.com",
Priority: cloudflare.Uint16Ptr(20),
TTL: 1,
Proxied: proxyDisabled,
},
},
},
[]string{endpoint.RecordTypeMX},
)
}
func TestCloudflareTxt(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "TXT",
DNSName: "txt.bar.com",
Targets: endpoint.Targets{"v=spf1 include:_spf.google.com ~all"},
},
}
AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("TXT", "txt.bar.com", "v=spf1 include:_spf.google.com ~all"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("TXT", "txt.bar.com", "v=spf1 include:_spf.google.com ~all"),
Type: "TXT",
Name: "txt.bar.com",
Content: "v=spf1 include:_spf.google.com ~all",
TTL: 1,
Proxied: proxyDisabled,
},
},
},
[]string{endpoint.RecordTypeTXT},
)
}
func TestCloudflareCustomTTL(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "A",
DNSName: "ttl.bar.com",
Targets: endpoint.Targets{"127.0.0.1"},
RecordTTL: 120,
},
}
AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("A", "ttl.bar.com", "127.0.0.1"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("A", "ttl.bar.com", "127.0.0.1"),
Type: "A",
Name: "ttl.bar.com",
Content: "127.0.0.1",
TTL: 120,
Proxied: proxyDisabled,
},
},
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareProxiedDefault(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "A",
DNSName: "bar.com",
Targets: endpoint.Targets{"127.0.0.1"},
},
}
AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
Type: "A",
Name: "bar.com",
Content: "127.0.0.1",
TTL: 1,
Proxied: proxyEnabled,
},
},
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareProxiedOverrideTrue(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "A",
DNSName: "bar.com",
Targets: endpoint.Targets{"127.0.0.1"},
ProviderSpecific: endpoint.ProviderSpecific{
endpoint.ProviderSpecificProperty{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "true",
},
},
},
}
AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
Type: "A",
Name: "bar.com",
Content: "127.0.0.1",
TTL: 1,
Proxied: proxyEnabled,
},
},
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareProxiedOverrideFalse(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "A",
DNSName: "bar.com",
Targets: endpoint.Targets{"127.0.0.1"},
ProviderSpecific: endpoint.ProviderSpecific{
endpoint.ProviderSpecificProperty{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "false",
},
},
},
}
AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
Type: "A",
Name: "bar.com",
Content: "127.0.0.1",
TTL: 1,
Proxied: proxyDisabled,
},
},
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareProxiedOverrideIllegal(t *testing.T) {
endpoints := []*endpoint.Endpoint{
{
RecordType: "A",
DNSName: "bar.com",
Targets: endpoint.Targets{"127.0.0.1"},
ProviderSpecific: endpoint.ProviderSpecific{
endpoint.ProviderSpecificProperty{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "asfasdfa",
},
},
},
}
AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
Type: "A",
Name: "bar.com",
Content: "127.0.0.1",
TTL: 1,
Proxied: proxyEnabled,
},
},
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareSetProxied(t *testing.T) {
var proxied *bool = proxyEnabled
var notProxied *bool = proxyDisabled
testCases := []struct {
recordType string
domain string
proxiable *bool
}{
{"A", "bar.com", proxied},
{"CNAME", "bar.com", proxied},
{"TXT", "bar.com", notProxied},
{"MX", "bar.com", notProxied},
{"NS", "bar.com", notProxied},
{"SPF", "bar.com", notProxied},
{"SRV", "bar.com", notProxied},
{"A", "*.bar.com", proxied},
{"CNAME", "*.docs.bar.com", proxied},
}
for _, testCase := range testCases {
var targets endpoint.Targets
var content string
var priority *uint16
if testCase.recordType == "MX" {
targets = endpoint.Targets{"10 mx.example.com"}
content = "mx.example.com"
priority = cloudflare.Uint16Ptr(10)
} else {
targets = endpoint.Targets{"127.0.0.1"}
content = "127.0.0.1"
}
endpoints := []*endpoint.Endpoint{
{
RecordType: testCase.recordType,
DNSName: testCase.domain,
Targets: endpoint.Targets{targets[0]},
ProviderSpecific: endpoint.ProviderSpecific{
endpoint.ProviderSpecificProperty{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "true",
},
},
},
}
expectedID := fmt.Sprintf("%s-%s-%s", testCase.domain, testCase.recordType, content)
recordData := cloudflare.DNSRecord{
ID: expectedID,
Type: testCase.recordType,
Name: testCase.domain,
Content: content,
TTL: 1,
Proxied: testCase.proxiable,
}
if testCase.recordType == "MX" {
recordData.Priority = priority
}
AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: expectedID,
RecordData: recordData,
},
}, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS, endpoint.RecordTypeMX}, testCase.recordType+" record on "+testCase.domain)
}
}
func TestCloudflareZones(t *testing.T) {
provider := &CloudFlareProvider{
Client: NewMockCloudFlareClient(),
domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
zones, err := provider.Zones(context.Background())
if err != nil {
t.Fatal(err)
}
assert.Len(t, zones, 1)
assert.Equal(t, "bar.com", zones[0].Name)
}
// test failures on zone lookup
func TestCloudflareZonesFailed(t *testing.T) {
client := NewMockCloudFlareClient()
client.getZoneError = errors.New("zone lookup failed")
provider := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{"001"}),
}
_, err := provider.Zones(context.Background())
if err == nil {
t.Errorf("should fail, %s", err)
}
}
func TestCloudFlareZonesWithIDFilter(t *testing.T) {
client := NewMockCloudFlareClient()
client.listZonesError = errors.New("shouldn't need to list zones when ZoneIDFilter in use")
provider := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"bar.com", "foo.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{"001"}),
}
zones, err := provider.Zones(context.Background())
if err != nil {
t.Fatal(err)
}
// foo.com should *not* be returned as it doesn't match ZoneID filter
assert.Len(t, zones, 1)
assert.Equal(t, "bar.com", zones[0].Name)
}
func TestCloudflareListZonesRateLimited(t *testing.T) {
// Create a mock client that returns a rate limit error
client := NewMockCloudFlareClient()
client.listZonesError = &cloudflare.Error{
StatusCode: 429,
ErrorCodes: []int{10000},
Type: cloudflare.ErrorTypeRateLimit,
}
p := &CloudFlareProvider{Client: client}
// Call the Zones function
_, err := p.Zones(context.Background())
// Assert that a soft error was returned
if !errors.Is(err, provider.SoftError) {
t.Error("expected a rate limit error")
}
}
func TestCloudflareListZonesRateLimitedStringError(t *testing.T) {
// Create a mock client that returns a rate limit error
client := NewMockCloudFlareClient()
client.listZonesError = errors.New("exceeded available rate limit retries")
p := &CloudFlareProvider{Client: client}
// Call the Zones function
_, err := p.Zones(context.Background())
// Assert that a soft error was returned
assert.ErrorIs(t, err, provider.SoftError, "expected a rate limit error")
}
func TestCloudflareListZoneInternalErrors(t *testing.T) {
// Create a mock client that returns a internal server error
client := NewMockCloudFlareClient()
client.listZonesError = &cloudflare.Error{
StatusCode: 500,
ErrorCodes: []int{20000},
Type: cloudflare.ErrorTypeService,
}
p := &CloudFlareProvider{Client: client}
// Call the Zones function
_, err := p.Zones(context.Background())
// Assert that a soft error was returned
t.Log(err)
if !errors.Is(err, provider.SoftError) {
t.Errorf("expected a internal error")
}
}
func TestCloudflareRecords(t *testing.T) {
client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{
"001": ExampleDomain,
})
// Set DNSRecordsPerPage to 1 test the pagination behaviour
p := &CloudFlareProvider{
Client: client,
DNSRecordsConfig: DNSRecordsConfig{PerPage: 1},
}
ctx := context.Background()
records, err := p.Records(ctx)
if err != nil {
t.Errorf("should not fail, %s", err)
}
assert.Len(t, records, 2)
client.dnsRecordsError = errors.New("failed to list dns records")
_, err = p.Records(ctx)
if err == nil {
t.Errorf("expected to fail")
}
client.dnsRecordsError = nil
client.listZonesError = &cloudflare.Error{
StatusCode: 429,
ErrorCodes: []int{10000},
Type: cloudflare.ErrorTypeRateLimit,
}
_, err = p.Records(ctx)
// Assert that a soft error was returned
if !errors.Is(err, provider.SoftError) {
t.Error("expected a rate limit error")
}
client.listZonesError = &cloudflare.Error{
StatusCode: 500,
ErrorCodes: []int{10000},
Type: cloudflare.ErrorTypeService,
}
_, err = p.Records(ctx)
// Assert that a soft error was returned
if !errors.Is(err, provider.SoftError) {
t.Error("expected a internal server error")
}
client.listZonesError = errors.New("failed to list zones")
_, err = p.Records(ctx)
if err == nil {
t.Errorf("expected to fail")
}
}
func TestCloudflareProvider(t *testing.T) {
var err error
type EnvVar struct {
Key string
Value string
}
tokenFile := "/tmp/cf_api_token"
if err := os.WriteFile(tokenFile, []byte("abc123def"), 0o644); err != nil {
t.Errorf("failed to write token file, %s", err)
}
testCases := []struct {
Name string
Environment []EnvVar
ShouldFail bool
}{
{
Name: "use_api_token",
Environment: []EnvVar{
{Key: "CF_API_TOKEN", Value: "abc123def"},
},
ShouldFail: false,
},
{
Name: "use_api_token_file_contents",
Environment: []EnvVar{
{Key: "CF_API_TOKEN", Value: tokenFile},
},
ShouldFail: false,
},
{
Name: "use_email_and_key",
Environment: []EnvVar{
{Key: "CF_API_KEY", Value: "xxxxxxxxxxxxxxxxx"},
{Key: "CF_API_EMAIL", Value: "test@test.com"},
},
ShouldFail: false,
},
{
Name: "no_use_email_and_key",
Environment: []EnvVar{},
ShouldFail: true,
},
{
Name: "use_credentials_in_missing_file",
Environment: []EnvVar{
{Key: "CF_API_TOKEN", Value: "file://abc"},
},
ShouldFail: true,
},
{
Name: "use_credentials_in_missing_file",
Environment: []EnvVar{
{Key: "CF_API_TOKEN", Value: "file:/tmp/cf_api_token"},
},
ShouldFail: false,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
for _, env := range tc.Environment {
t.Setenv(env.Key, env.Value)
}
_, err = NewCloudFlareProvider(
endpoint.NewDomainFilter([]string{"bar.com"}),
provider.NewZoneIDFilter([]string{""}),
false,
true,
RegionalServicesConfig{Enabled: false},
CustomHostnamesConfig{Enabled: false},
DNSRecordsConfig{PerPage: 5000, Comment: ""},
)
if err != nil && !tc.ShouldFail {
t.Errorf("should not fail, %s", err)
}
if err == nil && tc.ShouldFail {
t.Errorf("should fail, %s", err)
}
})
}
}
func TestCloudflareApplyChanges(t *testing.T) {
changes := &plan.Changes{}
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
}
changes.Create = []*endpoint.Endpoint{{
DNSName: "new.bar.com",
Targets: endpoint.Targets{"target"},
}, {
DNSName: "new.ext-dns-test.unrelated.to",
Targets: endpoint.Targets{"target"},
}}
changes.Delete = []*endpoint.Endpoint{{
DNSName: "foobar.bar.com",
Targets: endpoint.Targets{"target"},
}}
changes.Update = []*plan.Update{{
Old: &endpoint.Endpoint{
DNSName: "foobar.bar.com",
Targets: endpoint.Targets{"target-old"},
},
New: &endpoint.Endpoint{
DNSName: "foobar.bar.com",
Targets: endpoint.Targets{"target-new"},
},
}}
err := provider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
td.Cmp(t, client.Actions, []MockAction{
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("", "new.bar.com", "target"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("", "new.bar.com", "target"),
Name: "new.bar.com",
Content: "target",
TTL: 1,
Proxied: proxyDisabled,
},
},
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("", "foobar.bar.com", "target-new"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("", "foobar.bar.com", "target-new"),
Name: "foobar.bar.com",
Content: "target-new",
TTL: 1,
Proxied: proxyDisabled,
},
},
})
// empty changes
changes.Create = []*endpoint.Endpoint{}
changes.Delete = []*endpoint.Endpoint{}
changes.Update = []*plan.Update{}
err = provider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
}
func TestCloudflareDryRunApplyChanges(t *testing.T) {
changes := &plan.Changes{}
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
DryRun: true,
}
changes.Create = []*endpoint.Endpoint{{
DNSName: "new.bar.com",
Targets: endpoint.Targets{"target"},
}}
err := provider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
ctx := context.Background()
records, err := provider.Records(ctx)
if err != nil {
t.Errorf("should not fail, %s", err)
}
assert.Empty(t, records, "should not have any records")
}
func TestCloudflareApplyChangesError(t *testing.T) {
changes := &plan.Changes{}
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
}
changes.Create = []*endpoint.Endpoint{{
DNSName: "newerror.bar.com",
Targets: endpoint.Targets{"target"},
}}
err := provider.ApplyChanges(context.Background(), changes)
if err == nil {
t.Errorf("should fail, %s", err)
}
}
func TestCloudflareGetRecordID(t *testing.T) {
p := &CloudFlareProvider{}
recordsMap := DNSRecordsMap{
{Name: "foo.com", Type: endpoint.RecordTypeCNAME, Content: "foobar"}: {
Name: "foo.com",
Type: endpoint.RecordTypeCNAME,
Content: "foobar",
ID: "1",
},
{Name: "bar.de", Type: endpoint.RecordTypeA}: {
Name: "bar.de",
Type: endpoint.RecordTypeA,
ID: "2",
},
{Name: "bar.de", Type: endpoint.RecordTypeA, Content: "1.2.3.4"}: {
Name: "bar.de",
Type: endpoint.RecordTypeA,
Content: "1.2.3.4",
ID: "2",
},
}
assert.Empty(t, p.getRecordID(recordsMap, cloudflare.DNSRecord{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "foobar",
}))
assert.Empty(t, p.getRecordID(recordsMap, cloudflare.DNSRecord{
Name: "foo.com",
Type: endpoint.RecordTypeCNAME,
Content: "fizfuz",
}))
assert.Equal(t, "1", p.getRecordID(recordsMap, cloudflare.DNSRecord{
Name: "foo.com",
Type: endpoint.RecordTypeCNAME,
Content: "foobar",
}))
assert.Empty(t, p.getRecordID(recordsMap, cloudflare.DNSRecord{
Name: "bar.de",
Type: endpoint.RecordTypeA,
Content: "2.3.4.5",
}))
assert.Equal(t, "2", p.getRecordID(recordsMap, cloudflare.DNSRecord{
Name: "bar.de",
Type: endpoint.RecordTypeA,
Content: "1.2.3.4",
}))
}
func TestCloudflareGroupByNameAndType(t *testing.T) {
provider := &CloudFlareProvider{
Client: NewMockCloudFlareClient(),
domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
testCases := []struct {
Name string
Records []cloudflare.DNSRecord
ExpectedEndpoints []*endpoint.Endpoint
}{
{
Name: "empty",
Records: []cloudflare.DNSRecord{},
ExpectedEndpoints: []*endpoint.Endpoint{},
},
{
Name: "single record - single target",
Records: []cloudflare.DNSRecord{
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.1",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
},
ExpectedEndpoints: []*endpoint.Endpoint{
{
DNSName: "foo.com",
Targets: endpoint.Targets{"10.10.10.1"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "false",
},
},
},
},
},
{
Name: "single record - multiple targets",
Records: []cloudflare.DNSRecord{
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.1",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.2",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
},
ExpectedEndpoints: []*endpoint.Endpoint{
{
DNSName: "foo.com",
Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "false",
},
},
},
},
},
{
Name: "multiple record - multiple targets",
Records: []cloudflare.DNSRecord{
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.1",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.2",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
{
Name: "bar.de",
Type: endpoint.RecordTypeA,
Content: "10.10.10.1",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
{
Name: "bar.de",
Type: endpoint.RecordTypeA,
Content: "10.10.10.2",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
},
ExpectedEndpoints: []*endpoint.Endpoint{
{
DNSName: "foo.com",
Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "false",
},
},
},
{
DNSName: "bar.de",
Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "false",
},
},
},
},
},
{
Name: "multiple record - mixed single/multiple targets",
Records: []cloudflare.DNSRecord{
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.1",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.2",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
{
Name: "bar.de",
Type: endpoint.RecordTypeA,
Content: "10.10.10.1",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
},
ExpectedEndpoints: []*endpoint.Endpoint{
{
DNSName: "foo.com",
Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "false",
},
},
},
{
DNSName: "bar.de",
Targets: endpoint.Targets{"10.10.10.1"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "false",
},
},
},
},
},
{
Name: "unsupported record type",
Records: []cloudflare.DNSRecord{
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.1",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
{
Name: "foo.com",
Type: endpoint.RecordTypeA,
Content: "10.10.10.2",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
{
Name: "bar.de",
Type: "NOT SUPPORTED",
Content: "10.10.10.1",
TTL: defaultTTL,
Proxied: proxyDisabled,
},
},
ExpectedEndpoints: []*endpoint.Endpoint{
{
DNSName: "foo.com",
Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "false",
},
},
},
},
},
}
for _, tc := range testCases {
records := make(DNSRecordsMap)
for _, r := range tc.Records {
records[newDNSRecordIndex(r)] = r
}
endpoints := provider.groupByNameAndTypeWithCustomHostnames(records, CustomHostnamesMap{})
// Targets order could be random with underlying map
for _, ep := range endpoints {
slices.Sort(ep.Targets)
}
for _, ep := range tc.ExpectedEndpoints {
slices.Sort(ep.Targets)
}
assert.ElementsMatch(t, endpoints, tc.ExpectedEndpoints)
}
}
func TestGroupByNameAndTypeWithCustomHostnames_MX(t *testing.T) {
client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{
"001": {
{
ID: "mx-1",
Name: "mx.bar.com",
Type: endpoint.RecordTypeMX,
TTL: 3600,
Content: "mail.bar.com",
Priority: cloudflare.Uint16Ptr(10),
},
{
ID: "mx-2",
Name: "mx.bar.com",
Type: endpoint.RecordTypeMX,
TTL: 3600,
Content: "mail2.bar.com",
Priority: cloudflare.Uint16Ptr(20),
},
},
})
provider := &CloudFlareProvider{
Client: client,
}
ctx := context.Background()
chs := CustomHostnamesMap{}
records, err := provider.listDNSRecordsWithAutoPagination(ctx, "001")
assert.NoError(t, err)
endpoints := provider.groupByNameAndTypeWithCustomHostnames(records, chs)
assert.Len(t, endpoints, 1)
mxEndpoint := endpoints[0]
assert.Equal(t, "mx.bar.com", mxEndpoint.DNSName)
assert.Equal(t, endpoint.RecordTypeMX, mxEndpoint.RecordType)
assert.ElementsMatch(t, []string{"10 mail.bar.com", "20 mail2.bar.com"}, mxEndpoint.Targets)
assert.Equal(t, endpoint.TTL(3600), mxEndpoint.RecordTTL)
}
func TestProviderPropertiesIdempotency(t *testing.T) {
testCases := []struct {
Name string
ProviderProxiedByDefault bool
RecordsAreProxied *bool
ShouldBeUpdated bool
}{
{
Name: "ProxyDefault: false, ShouldBeProxied: false, ExpectUpdates: false",
ProviderProxiedByDefault: false,
RecordsAreProxied: proxyDisabled,
ShouldBeUpdated: false,
},
{
Name: "ProxyDefault: true, ShouldBeProxied: true, ExpectUpdates: false",
ProviderProxiedByDefault: true,
RecordsAreProxied: proxyEnabled,
ShouldBeUpdated: false,
},
{
Name: "ProxyDefault: true, ShouldBeProxied: false, ExpectUpdates: true",
ProviderProxiedByDefault: true,
RecordsAreProxied: proxyDisabled,
ShouldBeUpdated: true,
},
{
Name: "ProxyDefault: false, ShouldBeProxied: true, ExpectUpdates: true",
ProviderProxiedByDefault: false,
RecordsAreProxied: proxyEnabled,
ShouldBeUpdated: true,
},
}
for _, test := range testCases {
t.Run(test.Name, func(t *testing.T) {
client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{
"001": {
{
ID: "1234567890",
Name: "foobar.bar.com",
Type: endpoint.RecordTypeA,
TTL: 120,
Content: "1.2.3.4",
Proxied: test.RecordsAreProxied,
},
},
})
provider := &CloudFlareProvider{
Client: client,
proxiedByDefault: test.ProviderProxiedByDefault,
}
ctx := context.Background()
current, err := provider.Records(ctx)
if err != nil {
t.Errorf("should not fail, %s", err)
}
assert.Len(t, current, 1)
desired := []*endpoint.Endpoint{}
for _, c := range current {
// Copy all except ProviderSpecific fields
desired = append(desired, &endpoint.Endpoint{
DNSName: c.DNSName,
Targets: c.Targets,
RecordType: c.RecordType,
SetIdentifier: c.SetIdentifier,
RecordTTL: c.RecordTTL,
Labels: c.Labels,
})
}
desired, err = provider.AdjustEndpoints(desired)
assert.NoError(t, err)
plan := plan.Plan{
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
plan = *plan.Calculate()
assert.NotNil(t, plan.Changes, "should have plan")
if plan.Changes == nil {
return
}
assert.Empty(t, plan.Changes.Create, "should not have creates")
assert.Empty(t, plan.Changes.Delete, "should not have deletes")
if test.ShouldBeUpdated {
assert.Len(t, plan.Changes.UpdateNew(), 1, "should not have new updates")
assert.Len(t, plan.Changes.UpdateOld(), 1, "should not have old updates")
} else {
assert.Empty(t, plan.Changes.UpdateNew(), "should not have new updates")
assert.Empty(t, plan.Changes.UpdateOld(), "should not have old updates")
}
})
}
}
func TestCloudflareComplexUpdate(t *testing.T) {
client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{
"001": ExampleDomain,
})
provider := &CloudFlareProvider{
Client: client,
}
ctx := context.Background()
records, err := provider.Records(ctx)
if err != nil {
t.Errorf("should not fail, %s", err)
}
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
endpoints, err := provider.AdjustEndpoints([]*endpoint.Endpoint{
{
DNSName: "foobar.bar.com",
Targets: endpoint.Targets{"1.2.3.4", "2.3.4.5"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "true",
},
},
},
})
assert.NoError(t, err)
plan := &plan.Plan{
Current: records,
Desired: endpoints,
DomainFilter: endpoint.MatchAllDomainFilters{domainFilter},
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()
err = provider.ApplyChanges(context.Background(), planned.Changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
td.CmpDeeply(t, client.Actions, []MockAction{
{
Name: "Delete",
ZoneId: "001",
RecordId: "2345678901",
},
{
Name: "Create",
ZoneId: "001",
RecordId: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"),
RecordData: cloudflare.DNSRecord{
ID: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"),
Name: "foobar.bar.com",
Type: "A",
Content: "2.3.4.5",
TTL: 1,
Proxied: proxyEnabled,
},
},
{
Name: "Update",
ZoneId: "001",
RecordId: "1234567890",
RecordData: cloudflare.DNSRecord{
ID: "1234567890",
Name: "foobar.bar.com",
Type: "A",
Content: "1.2.3.4",
TTL: 1,
Proxied: proxyEnabled,
},
},
})
}
func TestCustomTTLWithEnabledProxyNotChanged(t *testing.T) {
client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{
"001": {
{
ID: "1234567890",
Name: "foobar.bar.com",
Type: endpoint.RecordTypeA,
TTL: 1,
Content: "1.2.3.4",
Proxied: proxyEnabled,
},
},
})
provider := &CloudFlareProvider{
Client: client,
}
records, err := provider.Records(context.Background())
if err != nil {
t.Errorf("should not fail, %s", err)
}
endpoints := []*endpoint.Endpoint{
{
DNSName: "foobar.bar.com",
Targets: endpoint.Targets{"1.2.3.4"},
RecordType: endpoint.RecordTypeA,
RecordTTL: 300,
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied",
Value: "true",
},
},
},
}
provider.AdjustEndpoints(endpoints)
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
plan := &plan.Plan{
Current: records,
Desired: endpoints,
DomainFilter: endpoint.MatchAllDomainFilters{domainFilter},
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()
assert.Empty(t, planned.Changes.Create, "no new changes should be here")
assert.Empty(t, planned.Changes.UpdateNew(), "no new changes should be here")
assert.Empty(t, planned.Changes.UpdateOld(), "no new changes should be here")
assert.Empty(t, planned.Changes.Delete, "no new changes should be here")
}
func TestCloudFlareProvider_Region(t *testing.T) {
t.Setenv("CF_API_TOKEN", "abc123def")
t.Setenv("CF_API_EMAIL", "test@test.com")
provider, err := NewCloudFlareProvider(
endpoint.NewDomainFilter([]string{"example.com"}),
provider.ZoneIDFilter{},
true,
false,
RegionalServicesConfig{Enabled: false, RegionKey: "us"},
CustomHostnamesConfig{Enabled: false},
DNSRecordsConfig{PerPage: 50, Comment: ""},
)
assert.NoError(t, err, "should not fail to create provider")
assert.True(t, provider.RegionalServicesConfig.Enabled, "expect regional services to be enabled")
assert.Equal(t, "us", provider.RegionalServicesConfig.RegionKey, "expected region key to be 'us'")
}
func TestCloudFlareProvider_newCloudFlareChange(t *testing.T) {
_ = os.Setenv("CF_API_KEY", "xxxxxxxxxxxxxxxxx")
_ = os.Setenv("CF_API_EMAIL", "test@test.com")
p, err := NewCloudFlareProvider(
endpoint.NewDomainFilter([]string{"example.com"}),
provider.ZoneIDFilter{},
true,
false,
RegionalServicesConfig{Enabled: true, RegionKey: "us"},
CustomHostnamesConfig{Enabled: false},
DNSRecordsConfig{PerPage: 50},
)
if err != nil {
t.Fatal(err)
}
ep := &endpoint.Endpoint{
DNSName: "example.com",
RecordType: "A",
Targets: []string{"192.0.2.1"},
}
change, _ := p.newCloudFlareChange(cloudFlareCreate, ep, ep.Targets[0], nil)
if change.RegionalHostname.regionKey != "us" {
t.Errorf("expected region key to be 'us', but got '%s'", change.RegionalHostname.regionKey)
}
var freeValidCommentBuilder strings.Builder
for range freeZoneMaxCommentLength {
freeValidCommentBuilder.WriteString("x")
}
var freeInvalidCommentBuilder strings.Builder
for range freeZoneMaxCommentLength + 1 {
freeInvalidCommentBuilder.WriteString("x")
}
var paidValidCommentBuilder strings.Builder
for range paidZoneMaxCommentLength {
paidValidCommentBuilder.WriteString("x")
}
var paidInvalidCommentBuilder strings.Builder
for range paidZoneMaxCommentLength + 1 {
paidInvalidCommentBuilder.WriteString("x")
}
paidProvider, err := NewCloudFlareProvider(
endpoint.NewDomainFilter([]string{"bar.com"}),
provider.ZoneIDFilter{},
true,
false,
RegionalServicesConfig{Enabled: true, RegionKey: "us"},
CustomHostnamesConfig{Enabled: false},
DNSRecordsConfig{PerPage: 50, Comment: paidValidCommentBuilder.String()},
)
if err != nil {
t.Fatal(err)
}
paidProvider.Client = NewMockCloudFlareClient()
commentTestCases := []struct {
name string
provider *CloudFlareProvider
endpoint *endpoint.Endpoint
expected int
}{
{
name: "For free Zone respecting comment length, expect no trimming",
provider: p,
endpoint: &endpoint.Endpoint{
DNSName: "example.com",
RecordType: "A",
Targets: []string{"192.0.2.1"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: annotations.CloudflareRecordCommentKey,
Value: freeValidCommentBuilder.String(),
},
},
},
expected: len(freeValidCommentBuilder.String()),
},
{
name: "For free Zones not respecting comment length, expect trimmed comments",
provider: p,
endpoint: &endpoint.Endpoint{
DNSName: "example.com",
RecordType: "A",
Targets: []string{"192.0.2.1"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: annotations.CloudflareRecordCommentKey,
Value: freeInvalidCommentBuilder.String(),
},
},
},
expected: freeZoneMaxCommentLength,
},
{
name: "For paid Zones respecting comment length, expect no trimming",
provider: paidProvider,
endpoint: &endpoint.Endpoint{
DNSName: "bar.com",
RecordType: "A",
Targets: []string{"192.0.2.1"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: annotations.CloudflareRecordCommentKey,
Value: paidValidCommentBuilder.String(),
},
},
},
expected: len(paidValidCommentBuilder.String()),
},
{
name: "For paid Zones not respecting comment length, expect trimmed comments",
provider: paidProvider,
endpoint: &endpoint.Endpoint{
DNSName: "bar.com",
RecordType: "A",
Targets: []string{"192.0.2.1"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: annotations.CloudflareRecordCommentKey,
Value: paidInvalidCommentBuilder.String(),
},
},
},
expected: paidZoneMaxCommentLength,
},
}
for _, test := range commentTestCases {
t.Run(test.name, func(t *testing.T) {
change, err := test.provider.newCloudFlareChange(cloudFlareCreate, test.endpoint, test.endpoint.Targets[0], nil)
assert.NoError(t, err)
if len(change.ResourceRecord.Comment) != test.expected {
t.Errorf("expected comment to be %d characters long, but got %d", test.expected, len(change.ResourceRecord.Comment))
}
})
}
}
func TestCloudFlareProvider_submitChangesCNAME(t *testing.T) {
client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{
"001": {
{
ID: "1234567890",
Name: "my-domain-here.app",
Type: endpoint.RecordTypeCNAME,
TTL: 1,
Content: "my-tunnel-guid-here.cfargotunnel.com",
Proxied: proxyEnabled,
},
{
ID: "9876543210",
Name: "my-domain-here.app",
Type: endpoint.RecordTypeTXT,
TTL: 1,
Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app",
},
},
})
// zoneIdFilter := provider.NewZoneIDFilter([]string{"001"})
provider := &CloudFlareProvider{
Client: client,
}
changes := []*cloudFlareChange{
{
Action: cloudFlareUpdate,
ResourceRecord: cloudflare.DNSRecord{
Name: "my-domain-here.app",
Type: endpoint.RecordTypeCNAME,
ID: "1234567890",
Content: "my-tunnel-guid-here.cfargotunnel.com",
},
RegionalHostname: regionalHostname{
hostname: "my-domain-here.app",
},
},
{
Action: cloudFlareUpdate,
ResourceRecord: cloudflare.DNSRecord{
Name: "my-domain-here.app",
Type: endpoint.RecordTypeTXT,
ID: "9876543210",
Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app",
},
RegionalHostname: regionalHostname{
hostname: "my-domain-here.app",
regionKey: "",
},
},
}
// Should not return an error
err := provider.submitChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
}
func TestCloudFlareProvider_submitChangesApex(t *testing.T) {
// Create a mock CloudFlare client with APEX records
client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{
"001": {
{
ID: "1234567890",
Name: "@", // APEX record
Type: endpoint.RecordTypeCNAME,
TTL: 1,
Content: "my-tunnel-guid-here.cfargotunnel.com",
Proxied: proxyEnabled,
},
{
ID: "9876543210",
Name: "@", // APEX record
Type: endpoint.RecordTypeTXT,
TTL: 1,
Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app",
},
},
})
// Create a CloudFlare provider instance
provider := &CloudFlareProvider{
Client: client,
}
// Define changes to submit
changes := []*cloudFlareChange{
{
Action: cloudFlareUpdate,
ResourceRecord: cloudflare.DNSRecord{
Name: "@", // APEX record
Type: endpoint.RecordTypeCNAME,
ID: "1234567890",
Content: "my-tunnel-guid-here.cfargotunnel.com",
},
RegionalHostname: regionalHostname{
hostname: "@", // APEX record
},
},
{
Action: cloudFlareUpdate,
ResourceRecord: cloudflare.DNSRecord{
Name: "@", // APEX record
Type: endpoint.RecordTypeTXT,
ID: "9876543210",
Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app",
},
RegionalHostname: regionalHostname{
hostname: "@", // APEX record
regionKey: "",
},
},
}
// Submit changes and verify no error is returned
err := provider.submitChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
}
func TestCloudflareZoneRecordsFail(t *testing.T) {
client := &mockCloudFlareClient{
Zones: map[string]string{
"newerror-001": "bar.com",
},
Records: map[string]map[string]cloudflare.DNSRecord{},
customHostnames: map[string][]cloudflare.CustomHostname{},
}
failingProvider := &CloudFlareProvider{
Client: client,
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
}
ctx := context.Background()
_, err := failingProvider.Records(ctx)
if err == nil {
t.Errorf("should fail - invalid zone id, %s", err)
}
}
// TestCloudflareLongRecordsErrorLog checks if the error is logged when a record name exceeds 63 characters
// it's not likely to happen in practice, as the Cloudflare API should reject having it
func TestCloudflareLongRecordsErrorLog(t *testing.T) {
client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{
"001": {
{
ID: "1234567890",
Name: "very-very-very-very-very-very-very-long-name-more-than-63-bytes-long.bar.com",
Type: endpoint.RecordTypeTXT,
TTL: 120,
Content: "some-content",
},
},
})
hook := testutils.LogsUnderTestWithLogLevel(log.InfoLevel, t)
p := &CloudFlareProvider{
Client: client,
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
}
ctx := context.Background()
_, err := p.Records(ctx)
if err != nil {
t.Errorf("should not fail - too long record, %s", err)
}
testutils.TestHelperLogContains("s longer than 63 characters. Cannot create endpoint", hook, t)
}
// check if the error is expected
func checkFailed(name string, err error, shouldFail bool) error {
if errors.Is(err, nil) && shouldFail {
return fmt.Errorf("should fail - %q", name)
}
if !errors.Is(err, nil) && !shouldFail {
return fmt.Errorf("should not fail - %q, %w", name, err)
}
return nil
}
func TestCloudflareDNSRecordsOperationsFail(t *testing.T) {
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
}
ctx := context.Background()
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
testFailCases := []struct {
Name string
Endpoints []*endpoint.Endpoint
ExpectedCustomHostnames map[string]string
shouldFail bool
}{
{
Name: "failing to create dns record",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "newerror.bar.com",
Targets: endpoint.Targets{"1.2.3.4"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
},
},
shouldFail: true,
},
{
Name: "adding failing to list DNS record",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "newerror-list-1.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.4"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
},
},
shouldFail: false,
},
{
Name: "causing to list failing to list DNS record",
Endpoints: []*endpoint.Endpoint{},
shouldFail: true,
},
{
Name: "create failing to update DNS record",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "newerror-update-1.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.4"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
},
},
shouldFail: false,
},
{
Name: "failing to update DNS record",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "newerror-update-1.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.4"},
RecordType: endpoint.RecordTypeA,
RecordTTL: 1234,
Labels: endpoint.Labels{},
},
},
shouldFail: true,
},
{
Name: "create failing to delete DNS record",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "newerror-delete-1.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.4"},
RecordType: endpoint.RecordTypeA,
RecordTTL: 1234,
Labels: endpoint.Labels{},
},
},
shouldFail: false,
},
{
Name: "failing to delete erroring DNS record",
Endpoints: []*endpoint.Endpoint{},
shouldFail: true,
},
}
for _, tc := range testFailCases {
var err error
var records, endpoints []*endpoint.Endpoint
records, err = provider.Records(ctx)
if errors.Is(err, nil) {
endpoints, err = provider.AdjustEndpoints(tc.Endpoints)
}
if errors.Is(err, nil) {
plan := &plan.Plan{
Current: records,
Desired: endpoints,
DomainFilter: endpoint.MatchAllDomainFilters{domainFilter},
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()
err = provider.ApplyChanges(context.Background(), planned.Changes)
}
if e := checkFailed(tc.Name, err, tc.shouldFail); !errors.Is(e, nil) {
t.Error(e)
}
}
}
func TestCloudflareCustomHostnameOperations(t *testing.T) {
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
}
ctx := context.Background()
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
testFailCases := []struct {
Name string
Endpoints []*endpoint.Endpoint
ExpectedCustomHostnames map[string]string
}{}
for _, tc := range testFailCases {
records, err := provider.Records(ctx)
if err != nil {
t.Errorf("should not fail, %v", err)
}
endpoints, err := provider.AdjustEndpoints(tc.Endpoints)
assert.NoError(t, err)
plan := &plan.Plan{
Current: records,
Desired: endpoints,
DomainFilter: endpoint.MatchAllDomainFilters{domainFilter},
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()
err = provider.ApplyChanges(context.Background(), planned.Changes)
if e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) {
t.Error(e)
}
chs, chErr := provider.listCustomHostnamesWithPagination(ctx, "001")
if e := checkFailed(tc.Name, chErr, false); !errors.Is(e, nil) {
t.Error(e)
}
actualCustomHostnames := map[string]string{}
for _, ch := range chs {
actualCustomHostnames[ch.Hostname] = ch.CustomOriginServer
}
if len(actualCustomHostnames) == 0 {
actualCustomHostnames = nil
}
assert.Equal(t, tc.ExpectedCustomHostnames, actualCustomHostnames, "custom hostnames should be the same")
}
}
func TestCloudflareDisabledCustomHostnameOperations(t *testing.T) {
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: false},
}
ctx := context.Background()
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
testCases := []struct {
Name string
Endpoints []*endpoint.Endpoint
testChanges bool
}{
{
Name: "add custom hostname",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "a.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.11"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "a.foo.fancybar.com",
},
},
},
{
DNSName: "b.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.12"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
},
{
DNSName: "c.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.13"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "c1.foo.fancybar.com",
},
},
},
},
testChanges: false,
},
{
Name: "add custom hostname",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "a.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.11"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
},
{
DNSName: "b.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.12"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "b.foo.fancybar.com",
},
},
},
{
DNSName: "c.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.13"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "c2.foo.fancybar.com",
},
},
},
},
testChanges: true,
},
}
for _, tc := range testCases {
records, err := provider.Records(ctx)
if err != nil {
t.Errorf("should not fail, %v", err)
}
endpoints, err := provider.AdjustEndpoints(tc.Endpoints)
assert.NoError(t, err)
plan := &plan.Plan{
Current: records,
Desired: endpoints,
DomainFilter: endpoint.MatchAllDomainFilters{domainFilter},
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()
err = provider.ApplyChanges(context.Background(), planned.Changes)
if e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) {
t.Error(e)
}
if tc.testChanges {
assert.False(t, planned.Changes.HasChanges(), "no new changes should be here")
}
}
}
func TestCloudflareCustomHostnameNotFoundOnRecordDeletion(t *testing.T) {
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
}
ctx := context.Background()
zoneID := "001"
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
testCases := []struct {
Name string
Endpoints []*endpoint.Endpoint
ExpectedCustomHostnames map[string]string
preApplyHook string
logOutput string
}{
{
Name: "create DNS record with custom hostname",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "create.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.4"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "newerror-getCustomHostnameOrigin.foo.fancybar.com",
},
},
},
},
preApplyHook: "",
logOutput: "",
},
{
Name: "remove DNS record with unexpectedly missing custom hostname",
Endpoints: []*endpoint.Endpoint{},
preApplyHook: "corrupt",
logOutput: "failed to delete custom hostname \"newerror-getCustomHostnameOrigin.foo.fancybar.com\": failed to get custom hostname: \"newerror-getCustomHostnameOrigin.foo.fancybar.com\" not found",
},
{
Name: "duplicate custom hostname",
Endpoints: []*endpoint.Endpoint{},
preApplyHook: "duplicate",
logOutput: "",
},
{
Name: "create DNS record with custom hostname",
Endpoints: []*endpoint.Endpoint{
{
DNSName: "a.foo.bar.com",
Targets: endpoint.Targets{"1.2.3.4"},
RecordType: endpoint.RecordTypeA,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "a.foo.fancybar.com",
},
},
},
},
preApplyHook: "",
logOutput: "custom hostname \"a.foo.fancybar.com\" already exists with the same origin \"a.foo.bar.com\", continue",
},
}
for _, tc := range testCases {
hook := testutils.LogsUnderTestWithLogLevel(log.InfoLevel, t)
records, err := provider.Records(ctx)
if err != nil {
t.Errorf("should not fail, %v", err)
}
endpoints, err := provider.AdjustEndpoints(tc.Endpoints)
assert.NoError(t, err)
plan := &plan.Plan{
Current: records,
Desired: endpoints,
DomainFilter: endpoint.MatchAllDomainFilters{domainFilter},
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()
// manually corrupt custom hostname before the deletion step
// the purpose is to cause getCustomHostnameOrigin() to fail on change.Action == cloudFlareDelete
chs, chErr := provider.listCustomHostnamesWithPagination(ctx, zoneID)
if e := checkFailed(tc.Name, chErr, false); !errors.Is(e, nil) {
t.Error(e)
}
if tc.preApplyHook == "corrupt" {
if ch, err := getCustomHostname(chs, "newerror-getCustomHostnameOrigin.foo.fancybar.com"); errors.Is(err, nil) {
chID := ch.ID
t.Logf("corrupting custom hostname %q", chID)
oldIdx := getCustomHostnameIdxByID(client.customHostnames[zoneID], chID)
oldCh := client.customHostnames[zoneID][oldIdx]
ch := cloudflare.CustomHostname{
Hostname: "corrupted-newerror-getCustomHostnameOrigin.foo.fancybar.com",
CustomOriginServer: oldCh.CustomOriginServer,
SSL: oldCh.SSL,
}
client.customHostnames[zoneID][oldIdx] = ch
}
} else if tc.preApplyHook == "duplicate" { // manually inject duplicating custom hostname with the same name and origin
ch := cloudflare.CustomHostname{
ID: "ID-random-123",
Hostname: "a.foo.fancybar.com",
CustomOriginServer: "a.foo.bar.com",
}
client.customHostnames[zoneID] = append(client.customHostnames[zoneID], ch)
}
err = provider.ApplyChanges(context.Background(), planned.Changes)
if e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) {
t.Error(e)
}
testutils.TestHelperLogContains(tc.logOutput, hook, t)
}
}
func TestCloudflareListCustomHostnamesWithPagionation(t *testing.T) {
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
}
ctx := context.Background()
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
const CustomHostnamesNumber = 342
var generatedEndpoints []*endpoint.Endpoint
for i := 0; i < CustomHostnamesNumber; i++ {
ep := []*endpoint.Endpoint{
{
DNSName: fmt.Sprintf("host-%d.foo.bar.com", i),
Targets: endpoint.Targets{fmt.Sprintf("cname-%d.foo.bar.com", i)},
RecordType: endpoint.RecordTypeCNAME,
RecordTTL: endpoint.TTL(defaultTTL),
Labels: endpoint.Labels{},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: fmt.Sprintf("host-%d.foo.fancybar.com", i),
},
},
},
}
generatedEndpoints = append(generatedEndpoints, ep...)
}
records, err := provider.Records(ctx)
if err != nil {
t.Errorf("should not fail, %v", err)
}
endpoints, err := provider.AdjustEndpoints(generatedEndpoints)
assert.NoError(t, err)
plan := &plan.Plan{
Current: records,
Desired: endpoints,
DomainFilter: endpoint.MatchAllDomainFilters{domainFilter},
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()
err = provider.ApplyChanges(context.Background(), planned.Changes)
if err != nil {
t.Errorf("should not fail - %v", err)
}
chs, chErr := provider.listCustomHostnamesWithPagination(ctx, "001")
if chErr != nil {
t.Errorf("should not fail - %v", chErr)
}
assert.Len(t, chs, CustomHostnamesNumber)
}
func TestZoneHasPaidPlan(t *testing.T) {
client := NewMockCloudFlareClient()
cfprovider := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
assert.False(t, cfprovider.ZoneHasPaidPlan("subdomain.foo.com"))
assert.True(t, cfprovider.ZoneHasPaidPlan("subdomain.bar.com"))
assert.False(t, cfprovider.ZoneHasPaidPlan("invaliddomain"))
client.getZoneError = errors.New("zone lookup failed")
cfproviderWithZoneError := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
assert.False(t, cfproviderWithZoneError.ZoneHasPaidPlan("subdomain.foo.com"))
}
func TestCloudflareApplyChanges_AllErrorLogPaths(t *testing.T) {
hook := testutils.LogsUnderTestWithLogLevel(log.ErrorLevel, t)
client := NewMockCloudFlareClient()
provider := &CloudFlareProvider{
Client: client,
}
cases := []struct {
name string
changes *plan.Changes
customHostnamesEnabled bool
errorLogCount int
}{
{
name: "Create error (custom hostnames enabled)",
changes: &plan.Changes{
Create: []*endpoint.Endpoint{{
DNSName: "bad-create.bar.com",
RecordType: "MX",
Targets: endpoint.Targets{"not-a-valid-mx"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "bad-create-custom.bar.com",
},
},
}},
},
customHostnamesEnabled: true,
errorLogCount: 1,
},
{
name: "Delete error (custom hostnames enabled)",
changes: &plan.Changes{
Delete: []*endpoint.Endpoint{{
DNSName: "bad-delete.bar.com",
RecordType: "MX",
Targets: endpoint.Targets{"not-a-valid-mx"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "bad-delete-custom.bar.com",
},
},
}},
},
customHostnamesEnabled: true,
errorLogCount: 1,
},
{
name: "Update add/remove error (custom hostnames enabled)",
changes: &plan.Changes{
Update: []*plan.Update{{
New: &endpoint.Endpoint{
DNSName: "bad-update-add.bar.com",
RecordType: "MX",
Targets: endpoint.Targets{"not-a-valid-mx"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "bad-update-add-custom.bar.com",
},
},
},
Old: &endpoint.Endpoint{
DNSName: "old-bad-update-add.bar.com",
RecordType: "MX",
Targets: endpoint.Targets{"not-a-valid-mx-but-still-updated"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "bad-update-add-custom.bar.com",
},
},
},
}},
},
customHostnamesEnabled: true,
errorLogCount: 2,
},
{
name: "Update leave error (custom hostnames enabled)",
changes: &plan.Changes{
Update: []*plan.Update{{
Old: &endpoint.Endpoint{
DNSName: "bad-update-leave.bar.com",
RecordType: "MX",
Targets: endpoint.Targets{"not-a-valid-mx"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "bad-update-leave-custom.bar.com",
},
},
},
New: &endpoint.Endpoint{
DNSName: "bad-update-leave.bar.com",
RecordType: "MX",
Targets: endpoint.Targets{"not-a-valid-mx"},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
Value: "bad-update-leave-custom.bar.com",
},
},
},
}},
},
customHostnamesEnabled: true,
errorLogCount: 1,
},
{
name: "Delete error (custom hostnames disabled)",
changes: &plan.Changes{
Delete: []*endpoint.Endpoint{{
DNSName: "bad-delete2.bar.com",
RecordType: "MX",
Targets: endpoint.Targets{"not-a-valid-mx"},
}},
},
customHostnamesEnabled: false,
errorLogCount: 1,
},
}
// Test with custom hostnames enabled and disabled
for _, tc := range cases {
if tc.customHostnamesEnabled {
provider.CustomHostnamesConfig = CustomHostnamesConfig{Enabled: true}
} else {
provider.CustomHostnamesConfig = CustomHostnamesConfig{Enabled: false}
}
hook.Reset()
err := provider.ApplyChanges(context.Background(), tc.changes)
assert.NoError(t, err, "ApplyChanges should not return error for newCloudFlareChange error (it should log and continue)")
errorLogCount := 0
for _, entry := range hook.Entries {
if entry.Level == log.ErrorLevel &&
strings.Contains(entry.Message, "failed to create cloudflare change") {
errorLogCount++
}
}
assert.Equal(t, tc.errorLogCount, errorLogCount, "expected error log count for %s", tc.name)
}
}
func TestCloudFlareProvider_SupportedAdditionalRecordTypes(t *testing.T) {
provider := &CloudFlareProvider{}
tests := []struct {
recordType string
expected bool
}{
{endpoint.RecordTypeMX, true},
{endpoint.RecordTypeA, true},
{endpoint.RecordTypeCNAME, true},
{endpoint.RecordTypeTXT, true},
{endpoint.RecordTypeNS, true},
{"SRV", true},
{"SPF", false},
{"LOC", false},
{"UNKNOWN", false},
}
for _, tt := range tests {
t.Run(tt.recordType, func(t *testing.T) {
result := provider.SupportedAdditionalRecordTypes(tt.recordType)
assert.Equal(t, tt.expected, result)
})
}
}
func TestCloudflareZoneChanges(t *testing.T) {
client := NewMockCloudFlareClient()
cfProvider := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
// Test zone listing and filtering
zones, err := cfProvider.Zones(context.Background())
assert.NoError(t, err)
assert.Len(t, zones, 2)
// Verify zone names
zoneNames := make([]string, len(zones))
for i, zone := range zones {
zoneNames[i] = zone.Name
}
assert.Contains(t, zoneNames, "foo.com")
assert.Contains(t, zoneNames, "bar.com")
// Test zone filtering with specific zone ID
providerWithZoneFilter := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{"001"}),
}
filteredZones, err := providerWithZoneFilter.Zones(context.Background())
assert.NoError(t, err)
assert.Len(t, filteredZones, 1)
assert.Equal(t, "bar.com", filteredZones[0].Name) // zone 001 is bar.com
assert.Equal(t, "001", filteredZones[0].ID)
// Test zone changes grouping
changes := []*cloudFlareChange{
{
Action: cloudFlareCreate,
ResourceRecord: cloudflare.DNSRecord{Name: "test1.foo.com", Type: "A", Content: "1.2.3.4"},
},
{
Action: cloudFlareCreate,
ResourceRecord: cloudflare.DNSRecord{Name: "test2.foo.com", Type: "A", Content: "1.2.3.5"},
},
{
Action: cloudFlareCreate,
ResourceRecord: cloudflare.DNSRecord{Name: "test1.bar.com", Type: "A", Content: "1.2.3.6"},
},
}
changesByZone := cfProvider.changesByZone(zones, changes)
assert.Len(t, changesByZone, 2)
assert.Len(t, changesByZone["001"], 1) // bar.com zone (test1.bar.com)
assert.Len(t, changesByZone["002"], 2) // foo.com zone (test1.foo.com, test2.foo.com)
// Test paid plan detection
assert.False(t, cfProvider.ZoneHasPaidPlan("subdomain.foo.com")) // free plan
assert.True(t, cfProvider.ZoneHasPaidPlan("subdomain.bar.com")) // paid plan
}
func TestCloudflareZoneErrors(t *testing.T) {
client := NewMockCloudFlareClient()
// Test list zones error
client.listZonesError = errors.New("failed to list zones")
cfProvider := &CloudFlareProvider{
Client: client,
}
zones, err := cfProvider.Zones(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to list zones")
assert.Nil(t, zones)
// Test get zone error
client.listZonesError = nil
client.getZoneError = errors.New("failed to get zone")
// This should still work for listing but fail when getting individual zones
zones, err = cfProvider.Zones(context.Background())
assert.NoError(t, err) // List works, individual gets may fail internally
assert.NotNil(t, zones)
}
func TestCloudflareZoneFiltering(t *testing.T) {
client := NewMockCloudFlareClient()
// Test with domain filter only
cfProvider := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"foo.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
zones, err := cfProvider.Zones(context.Background())
assert.NoError(t, err)
assert.Len(t, zones, 1)
assert.Equal(t, "foo.com", zones[0].Name)
// Test with zone ID filter
providerWithIDFilter := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{}),
zoneIDFilter: provider.NewZoneIDFilter([]string{"002"}),
}
filteredZones, err := providerWithIDFilter.Zones(context.Background())
assert.NoError(t, err)
assert.Len(t, filteredZones, 1)
assert.Equal(t, "foo.com", filteredZones[0].Name) // zone 002 is foo.com
assert.Equal(t, "002", filteredZones[0].ID)
}
func TestCloudflareZonePlanDetection(t *testing.T) {
client := NewMockCloudFlareClient()
cfProvider := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
// Test free plan detection (foo.com)
assert.False(t, cfProvider.ZoneHasPaidPlan("foo.com"))
assert.False(t, cfProvider.ZoneHasPaidPlan("subdomain.foo.com"))
assert.False(t, cfProvider.ZoneHasPaidPlan("deep.subdomain.foo.com"))
// Test paid plan detection (bar.com)
assert.True(t, cfProvider.ZoneHasPaidPlan("bar.com"))
assert.True(t, cfProvider.ZoneHasPaidPlan("subdomain.bar.com"))
assert.True(t, cfProvider.ZoneHasPaidPlan("deep.subdomain.bar.com"))
// Test invalid domain
assert.False(t, cfProvider.ZoneHasPaidPlan("invalid.domain.com"))
// Test with zone error
client.getZoneError = errors.New("zone lookup failed")
providerWithError := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
assert.False(t, providerWithError.ZoneHasPaidPlan("subdomain.foo.com"))
}
func TestCloudflareChangesByZone(t *testing.T) {
client := NewMockCloudFlareClient()
cfProvider := &CloudFlareProvider{
Client: client,
domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}),
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
}
zones, err := cfProvider.Zones(context.Background())
assert.NoError(t, err)
assert.Len(t, zones, 2)
// Test empty changes
emptyChanges := []*cloudFlareChange{}
changesByZone := cfProvider.changesByZone(zones, emptyChanges)
assert.Len(t, changesByZone, 2) // Should return map with zones but empty slices
assert.Empty(t, changesByZone["001"]) // bar.com zone should have no changes
assert.Empty(t, changesByZone["002"]) // foo.com zone should have no changes
// Test changes for different zones
changes := []*cloudFlareChange{
{
Action: cloudFlareCreate,
ResourceRecord: cloudflare.DNSRecord{Name: "api.foo.com", Type: "A", Content: "1.2.3.4"},
},
{
Action: cloudFlareUpdate,
ResourceRecord: cloudflare.DNSRecord{Name: "www.foo.com", Type: "CNAME", Content: "foo.com"},
},
{
Action: cloudFlareCreate,
ResourceRecord: cloudflare.DNSRecord{Name: "mail.bar.com", Type: "MX", Content: "10 mail.bar.com"},
},
{
Action: cloudFlareDelete,
ResourceRecord: cloudflare.DNSRecord{Name: "old.bar.com", Type: "A", Content: "5.6.7.8"},
},
}
changesByZone = cfProvider.changesByZone(zones, changes)
assert.Len(t, changesByZone, 2)
// Verify bar.com zone changes (zone 001)
barChanges := changesByZone["001"]
assert.Len(t, barChanges, 2)
assert.Equal(t, "mail.bar.com", barChanges[0].ResourceRecord.Name)
assert.Equal(t, "old.bar.com", barChanges[1].ResourceRecord.Name)
// Verify foo.com zone changes (zone 002)
fooChanges := changesByZone["002"]
assert.Len(t, fooChanges, 2)
assert.Equal(t, "api.foo.com", fooChanges[0].ResourceRecord.Name)
assert.Equal(t, "www.foo.com", fooChanges[1].ResourceRecord.Name)
}
func TestConvertCloudflareError(t *testing.T) {
tests := []struct {
name string
inputError error
expectSoftError bool
description string
}{
{
name: "Rate limit error via Error type",
inputError: &cloudflare.Error{StatusCode: 429, Type: cloudflare.ErrorTypeRateLimit},
expectSoftError: true,
description: "CloudFlare API rate limit error should be converted to soft error",
},
{
name: "Rate limit error via ClientRateLimited",
inputError: &cloudflare.Error{StatusCode: 429, ErrorCodes: []int{10000}, Type: cloudflare.ErrorTypeRateLimit}, // Complete rate limit error
expectSoftError: true,
description: "CloudFlare client rate limited error should be converted to soft error",
},
{
name: "Server error 500",
inputError: &cloudflare.Error{StatusCode: 500},
expectSoftError: true,
description: "Server error (500+) should be converted to soft error",
},
{
name: "Server error 502",
inputError: &cloudflare.Error{StatusCode: 502},
expectSoftError: true,
description: "Server error (502) should be converted to soft error",
},
{
name: "Server error 503",
inputError: &cloudflare.Error{StatusCode: 503},
expectSoftError: true,
description: "Server error (503) should be converted to soft error",
},
{
name: "Rate limit string error",
inputError: errors.New("exceeded available rate limit retries"),
expectSoftError: true,
description: "String error containing rate limit message should be converted to soft error",
},
{
name: "Rate limit string error mixed case",
inputError: errors.New("request failed: exceeded available rate limit retries for this operation"),
expectSoftError: true,
description: "String error containing rate limit message should be converted to soft error regardless of context",
},
{
name: "Client error 400",
inputError: &cloudflare.Error{StatusCode: 400},
expectSoftError: false,
description: "Client error (400) should not be converted to soft error",
},
{
name: "Client error 401",
inputError: &cloudflare.Error{StatusCode: 401},
expectSoftError: false,
description: "Client error (401) should not be converted to soft error",
},
{
name: "Client error 404",
inputError: &cloudflare.Error{StatusCode: 404},
expectSoftError: false,
description: "Client error (404) should not be converted to soft error",
},
{
name: "Generic error",
inputError: errors.New("some generic error"),
expectSoftError: false,
description: "Generic error should not be converted to soft error",
},
{
name: "Network error",
inputError: errors.New("connection refused"),
expectSoftError: false,
description: "Network error should not be converted to soft error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertCloudflareError(tt.inputError)
if tt.expectSoftError {
assert.ErrorIs(t, result, provider.SoftError,
"Expected soft error for %s: %s", tt.name, tt.description)
// Verify the original error message is preserved in the soft error
assert.Contains(t, result.Error(), tt.inputError.Error(),
"Original error message should be preserved")
} else {
assert.NotErrorIs(t, result, provider.SoftError,
"Expected non-soft error for %s: %s", tt.name, tt.description)
assert.Equal(t, tt.inputError, result,
"Non-soft errors should be returned unchanged")
}
})
}
}
func TestConvertCloudflareErrorInContext(t *testing.T) {
tests := []struct {
name string
setupMock func(*mockCloudFlareClient)
function func(*CloudFlareProvider) error
expectSoftError bool
description string
}{
{
name: "Zones with GetZone rate limit error",
setupMock: func(client *mockCloudFlareClient) {
client.Zones = map[string]string{"zone1": "example.com"}
client.getZoneError = &cloudflare.Error{StatusCode: 429, Type: cloudflare.ErrorTypeRateLimit}
},
function: func(p *CloudFlareProvider) error {
p.zoneIDFilter.ZoneIDs = []string{"zone1"}
_, err := p.Zones(context.Background())
return err
},
expectSoftError: true,
description: "Zones function should convert GetZone rate limit errors to soft errors",
},
{
name: "Zones with GetZone server error",
setupMock: func(client *mockCloudFlareClient) {
client.Zones = map[string]string{"zone1": "example.com"}
client.getZoneError = &cloudflare.Error{StatusCode: 500}
},
function: func(p *CloudFlareProvider) error {
p.zoneIDFilter.ZoneIDs = []string{"zone1"}
_, err := p.Zones(context.Background())
return err
},
expectSoftError: true,
description: "Zones function should convert GetZone server errors to soft errors",
},
{
name: "Zones with GetZone client error",
setupMock: func(client *mockCloudFlareClient) {
client.Zones = map[string]string{"zone1": "example.com"}
client.getZoneError = &cloudflare.Error{StatusCode: 404}
},
function: func(p *CloudFlareProvider) error {
p.zoneIDFilter.ZoneIDs = []string{"zone1"}
_, err := p.Zones(context.Background())
return err
},
expectSoftError: false,
description: "Zones function should not convert GetZone client errors to soft errors",
},
{
name: "Zones with ListZones rate limit error",
setupMock: func(client *mockCloudFlareClient) {
client.listZonesError = errors.New("exceeded available rate limit retries")
},
function: func(p *CloudFlareProvider) error {
_, err := p.Zones(context.Background())
return err
},
expectSoftError: true,
description: "Zones function should convert ListZones rate limit string errors to soft errors",
},
{
name: "Zones with ListZones server error",
setupMock: func(client *mockCloudFlareClient) {
client.listZonesError = &cloudflare.Error{StatusCode: 503}
},
function: func(p *CloudFlareProvider) error {
_, err := p.Zones(context.Background())
return err
},
expectSoftError: true,
description: "Zones function should convert ListZones server errors to soft errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewMockCloudFlareClient()
tt.setupMock(client)
p := &CloudFlareProvider{
Client: client,
zoneIDFilter: provider.ZoneIDFilter{},
}
err := tt.function(p)
assert.Error(t, err, "Expected an error from %s", tt.name)
if tt.expectSoftError {
assert.ErrorIs(t, err, provider.SoftError,
"Expected soft error for %s: %s", tt.name, tt.description)
} else {
assert.NotErrorIs(t, err, provider.SoftError,
"Expected non-soft error for %s: %s", tt.name, tt.description)
}
})
}
}
func TestCloudFlareZonesDomainFilter(t *testing.T) {
// Set required environment variables for CloudFlare provider
t.Setenv("CF_API_TOKEN", "test-token")
client := NewMockCloudFlareClient()
// Create a domain filter that only matches "bar.com"
// This should filter out "foo.com" and trigger the debug log
domainFilter := endpoint.NewDomainFilter([]string{"bar.com"})
p, err := NewCloudFlareProvider(
domainFilter,
provider.NewZoneIDFilter([]string{""}), // empty zone ID filter so it uses ListZones path
false, // proxied
false, // dry run
RegionalServicesConfig{},
CustomHostnamesConfig{},
DNSRecordsConfig{PerPage: 50},
)
require.NoError(t, err)
// Replace the real client with our mock
p.Client = client
// Capture debug logs to verify the filter log message
oldLevel := log.GetLevel()
log.SetLevel(log.DebugLevel)
defer log.SetLevel(oldLevel)
// Use a custom formatter to capture log output
var logOutput strings.Builder
log.SetOutput(&logOutput)
defer log.SetOutput(os.Stderr)
// Call Zones() which should trigger the domain filter logic
zones, err := p.Zones(context.Background())
require.NoError(t, err)
// Should only return the "bar.com" zone since "foo.com" is filtered out
assert.Len(t, zones, 1)
assert.Equal(t, "bar.com", zones[0].Name)
assert.Equal(t, "001", zones[0].ID)
// Verify that the debug log was written for the filtered zone
logString := logOutput.String()
assert.Contains(t, logString, `zone \"foo.com\" not in domain filter`)
assert.Contains(t, logString, "no zoneIDFilter configured, looking at all zones")
}
func TestZoneIDByNameIteratorError(t *testing.T) {
client := NewMockCloudFlareClient()
// Set up an error that will be returned by the ListZones iterator (line 144)
client.listZonesError = fmt.Errorf("CloudFlare API connection timeout")
// Call ZoneIDByName which should hit line 144 (iterator error handling)
zoneID, err := client.ZoneIDByName("example.com")
// Should return empty zone ID and the wrapped iterator error
assert.Empty(t, zoneID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to list zones from CloudFlare API")
assert.Contains(t, err.Error(), "CloudFlare API connection timeout")
}
func TestZoneIDByNameZoneNotFound(t *testing.T) {
client := NewMockCloudFlareClient()
// Set up mock to return different zones but not the one we're looking for
client.Zones = map[string]string{
"zone456": "different.com",
"zone789": "another.com",
}
// Call ZoneIDByName for a zone that doesn't exist, should hit line 147 (zone not found)
zoneID, err := client.ZoneIDByName("nonexistent.com")
// Should return empty zone ID and the improved error message
assert.Empty(t, zoneID)
assert.Error(t, err)
assert.Contains(t, err.Error(), `zone "nonexistent.com" not found in CloudFlare account`)
assert.Contains(t, err.Error(), "verify the zone exists and API credentials have access to it")
}