mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2026-05-04 14:21:33 +02:00
3228 lines
93 KiB
Go
3228 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"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/cloudflare/cloudflare-go/v5"
|
|
"github.com/cloudflare/cloudflare-go/v5/dns"
|
|
"github.com/cloudflare/cloudflare-go/v5/option"
|
|
"github.com/cloudflare/cloudflare-go/v5/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"
|
|
logtest "sigs.k8s.io/external-dns/internal/testutils/log"
|
|
"sigs.k8s.io/external-dns/plan"
|
|
"sigs.k8s.io/external-dns/provider"
|
|
"sigs.k8s.io/external-dns/source/annotations"
|
|
)
|
|
|
|
// newCloudflareError creates a cloudflare.Error suitable for testing.
|
|
// The v5 SDK's Error type panics when .Error() is called with nil Request/Response fields,
|
|
// so this helper initializes them properly.
|
|
func newCloudflareError(statusCode int) *cloudflare.Error {
|
|
req := httptest.NewRequest(http.MethodGet, "https://api.cloudflare.com/client/v4/zones", nil)
|
|
resp := &http.Response{
|
|
StatusCode: statusCode,
|
|
Status: http.StatusText(statusCode),
|
|
Request: req,
|
|
}
|
|
return &cloudflare.Error{
|
|
StatusCode: statusCode,
|
|
Request: req,
|
|
Response: resp,
|
|
}
|
|
}
|
|
|
|
var ExampleDomain = []dns.RecordResponse{
|
|
{
|
|
ID: "1234567890",
|
|
Name: "foobar.bar.com",
|
|
Type: endpoint.RecordTypeA,
|
|
TTL: 120,
|
|
Content: "1.2.3.4",
|
|
Proxied: false,
|
|
Comment: "valid comment",
|
|
},
|
|
{
|
|
ID: "2345678901",
|
|
Name: "foobar.bar.com",
|
|
Type: endpoint.RecordTypeA,
|
|
TTL: 120,
|
|
Content: "3.4.5.6",
|
|
Proxied: false,
|
|
},
|
|
{
|
|
ID: "1231231233",
|
|
Name: "bar.foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
TTL: 1,
|
|
Content: "2.3.4.5",
|
|
Proxied: false,
|
|
},
|
|
}
|
|
|
|
type MockAction struct {
|
|
Name string
|
|
ZoneId string
|
|
RecordId string
|
|
RecordData dns.RecordResponse
|
|
RegionalHostname regionalHostname
|
|
}
|
|
|
|
type mockCloudFlareClient struct {
|
|
Zones map[string]string
|
|
Records map[string]map[string]dns.RecordResponse
|
|
Actions []MockAction
|
|
BatchDNSRecordsCalls int
|
|
listZonesError error // For v4 ListZones
|
|
getZoneError error // For v4 GetZone
|
|
dnsRecordsError error
|
|
customHostnames map[string][]customHostname
|
|
regionalHostnames map[string][]regionalHostname
|
|
dnsRecordsListParams dns.RecordListParams
|
|
}
|
|
|
|
func NewMockCloudFlareClient() *mockCloudFlareClient {
|
|
return &mockCloudFlareClient{
|
|
Zones: map[string]string{
|
|
"001": "bar.com",
|
|
"002": "foo.com",
|
|
},
|
|
Records: map[string]map[string]dns.RecordResponse{
|
|
"001": {},
|
|
"002": {},
|
|
},
|
|
customHostnames: map[string][]customHostname{},
|
|
regionalHostnames: map[string][]regionalHostname{},
|
|
}
|
|
}
|
|
|
|
func NewMockCloudFlareClientWithRecords(records map[string][]dns.RecordResponse) *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 (m *mockCloudFlareClient) CreateDNSRecord(_ context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error) {
|
|
body := params.Body.(dns.RecordNewParamsBody)
|
|
|
|
record := dns.RecordResponse{
|
|
ID: generateDNSRecordID(body.Type.String(), body.Name.Value, body.Content.Value),
|
|
Name: body.Name.Value,
|
|
TTL: dns.TTL(body.TTL.Value),
|
|
Proxied: body.Proxied.Value,
|
|
Type: dns.RecordResponseType(body.Type.String()),
|
|
Content: body.Content.Value,
|
|
Priority: body.Priority.Value,
|
|
}
|
|
|
|
m.Actions = append(m.Actions, MockAction{
|
|
Name: "Create",
|
|
ZoneId: params.ZoneID.Value,
|
|
RecordId: record.ID,
|
|
RecordData: record,
|
|
})
|
|
if zone, ok := m.Records[params.ZoneID.Value]; ok {
|
|
zone[record.ID] = record
|
|
}
|
|
|
|
if record.Name == "newerror.bar.com" {
|
|
return nil, fmt.Errorf("failed to create record")
|
|
}
|
|
return &record, nil
|
|
}
|
|
|
|
func (m *mockCloudFlareClient) ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse] {
|
|
m.dnsRecordsListParams = params
|
|
if m.dnsRecordsError != nil {
|
|
return &mockAutoPager[dns.RecordResponse]{err: m.dnsRecordsError}
|
|
}
|
|
iter := &mockAutoPager[dns.RecordResponse]{}
|
|
if zone, ok := m.Records[params.ZoneID.Value]; ok {
|
|
for _, record := range zone {
|
|
if strings.HasPrefix(record.Name, "newerror-list-") {
|
|
m.DeleteDNSRecord(ctx, record.ID, dns.RecordDeleteParams{ZoneID: params.ZoneID})
|
|
iter.err = errors.New("failed to list erroring DNS record")
|
|
return iter
|
|
}
|
|
iter.items = append(iter.items, record)
|
|
}
|
|
}
|
|
return iter
|
|
}
|
|
|
|
func (m *mockCloudFlareClient) UpdateDNSRecord(_ context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error) {
|
|
zoneID := params.ZoneID.String()
|
|
body := params.Body.(dns.RecordUpdateParamsBody)
|
|
|
|
record := dns.RecordResponse{
|
|
ID: recordID,
|
|
Name: body.Name.Value,
|
|
TTL: dns.TTL(body.TTL.Value),
|
|
Proxied: body.Proxied.Value,
|
|
Type: dns.RecordResponseType(body.Type.String()),
|
|
Content: body.Content.Value,
|
|
Priority: body.Priority.Value,
|
|
}
|
|
|
|
m.Actions = append(m.Actions, MockAction{
|
|
Name: "Update",
|
|
ZoneId: zoneID,
|
|
RecordId: recordID,
|
|
RecordData: record,
|
|
})
|
|
if zone, ok := m.Records[zoneID]; ok {
|
|
if _, ok := zone[recordID]; ok {
|
|
if strings.HasPrefix(record.Name, "newerror-update-") {
|
|
return nil, errors.New("failed to update erroring DNS record")
|
|
}
|
|
zone[recordID] = record
|
|
}
|
|
}
|
|
return &record, nil
|
|
}
|
|
|
|
func (m *mockCloudFlareClient) DeleteDNSRecord(_ context.Context, recordID string, params dns.RecordDeleteParams) error {
|
|
zoneID := params.ZoneID.String()
|
|
m.Actions = append(m.Actions, MockAction{
|
|
Name: "Delete",
|
|
ZoneId: zoneID,
|
|
RecordId: recordID,
|
|
})
|
|
if zone, ok := m.Records[zoneID]; 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) 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)
|
|
}
|
|
|
|
func (m *mockCloudFlareClient) ListZones(_ context.Context, _ 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(_ 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 AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, managedRecords []string, args ...any) {
|
|
t.Helper()
|
|
|
|
var client *mockCloudFlareClient
|
|
|
|
if provider.Client == nil {
|
|
client = NewMockCloudFlareClient()
|
|
provider.Client = client
|
|
} else {
|
|
client = provider.Client.(*mockCloudFlareClient)
|
|
}
|
|
|
|
ctx := t.Context()
|
|
|
|
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(t.Context(), 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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
|
|
Type: "A",
|
|
Name: "bar.com",
|
|
Content: "127.0.0.1",
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
{
|
|
Name: "Create",
|
|
ZoneId: "001",
|
|
RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.2"),
|
|
RecordData: dns.RecordResponse{
|
|
ID: generateDNSRecordID("A", "bar.com", "127.0.0.2"),
|
|
Type: "A",
|
|
Name: "bar.com",
|
|
Content: "127.0.0.2",
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
},
|
|
[]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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("CNAME", "cname.bar.com", "google.com"),
|
|
Type: "CNAME",
|
|
Name: "cname.bar.com",
|
|
Content: "google.com",
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
{
|
|
Name: "Create",
|
|
ZoneId: "001",
|
|
RecordId: generateDNSRecordID("CNAME", "cname.bar.com", "facebook.com"),
|
|
RecordData: dns.RecordResponse{
|
|
ID: generateDNSRecordID("CNAME", "cname.bar.com", "facebook.com"),
|
|
Type: "CNAME",
|
|
Name: "cname.bar.com",
|
|
Content: "facebook.com",
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
},
|
|
[]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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("MX", "mx.bar.com", "google.com"),
|
|
Type: "MX",
|
|
Name: "mx.bar.com",
|
|
Content: "google.com",
|
|
Priority: 10,
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
{
|
|
Name: "Create",
|
|
ZoneId: "001",
|
|
RecordId: generateDNSRecordID("MX", "mx.bar.com", "facebook.com"),
|
|
RecordData: dns.RecordResponse{
|
|
ID: generateDNSRecordID("MX", "mx.bar.com", "facebook.com"),
|
|
Type: "MX",
|
|
Name: "mx.bar.com",
|
|
Content: "facebook.com",
|
|
Priority: 20,
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
},
|
|
[]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: dns.RecordResponse{
|
|
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: false,
|
|
},
|
|
},
|
|
},
|
|
[]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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("A", "ttl.bar.com", "127.0.0.1"),
|
|
Type: "A",
|
|
Name: "ttl.bar.com",
|
|
Content: "127.0.0.1",
|
|
TTL: 120,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
},
|
|
[]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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
|
|
Type: "A",
|
|
Name: "bar.com",
|
|
Content: "127.0.0.1",
|
|
TTL: 1,
|
|
Proxied: true,
|
|
},
|
|
},
|
|
},
|
|
[]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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
|
|
Type: "A",
|
|
Name: "bar.com",
|
|
Content: "127.0.0.1",
|
|
TTL: 1,
|
|
Proxied: true,
|
|
},
|
|
},
|
|
},
|
|
[]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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
|
|
Type: "A",
|
|
Name: "bar.com",
|
|
Content: "127.0.0.1",
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
},
|
|
[]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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"),
|
|
Type: "A",
|
|
Name: "bar.com",
|
|
Content: "127.0.0.1",
|
|
TTL: 1,
|
|
Proxied: true,
|
|
},
|
|
},
|
|
},
|
|
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
|
|
)
|
|
}
|
|
|
|
func TestCloudflareSetProxied(t *testing.T) {
|
|
testCases := []struct {
|
|
recordType string
|
|
domain string
|
|
proxiable bool
|
|
}{
|
|
{"A", "bar.com", true},
|
|
{"CNAME", "bar.com", true},
|
|
{"TXT", "bar.com", false},
|
|
{"MX", "bar.com", false},
|
|
{"NS", "bar.com", false},
|
|
{"SPF", "bar.com", false},
|
|
{"SRV", "bar.com", false},
|
|
{"A", "*.bar.com", true},
|
|
{"CNAME", "*.docs.bar.com", true},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(fmt.Sprint(testCase), func(t *testing.T) {
|
|
var targets endpoint.Targets
|
|
var content string
|
|
var priority float64
|
|
|
|
if testCase.recordType == "MX" {
|
|
targets = endpoint.Targets{"10 mx.example.com"}
|
|
content = "mx.example.com"
|
|
priority = 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 := dns.RecordResponse{
|
|
ID: expectedID,
|
|
Type: dns.RecordResponseType(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(t.Context())
|
|
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(t.Context())
|
|
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(t.Context())
|
|
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 = newCloudflareError(429)
|
|
p := &CloudFlareProvider{Client: client}
|
|
|
|
// Call the Zones function
|
|
_, err := p.Zones(t.Context())
|
|
|
|
// 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(t.Context())
|
|
|
|
// 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 = newCloudflareError(500)
|
|
p := &CloudFlareProvider{Client: client}
|
|
|
|
// Call the Zones function
|
|
_, err := p.Zones(t.Context())
|
|
|
|
// 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][]dns.RecordResponse{
|
|
"001": ExampleDomain,
|
|
})
|
|
|
|
// Set DNSRecordsPerPage to 1 test the pagination behaviour
|
|
p := &CloudFlareProvider{
|
|
Client: client,
|
|
DNSRecordsConfig: DNSRecordsConfig{PerPage: 1},
|
|
}
|
|
ctx := t.Context()
|
|
|
|
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 = newCloudflareError(429)
|
|
_, 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 = newCloudflareError(500)
|
|
_, 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 TestGetDNSRecordsMapWithPerPage(t *testing.T) {
|
|
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
|
"001": ExampleDomain,
|
|
})
|
|
|
|
ctx := t.Context()
|
|
|
|
t.Run("PerPage set to positive value", func(t *testing.T) {
|
|
provider := &CloudFlareProvider{
|
|
Client: client,
|
|
DNSRecordsConfig: DNSRecordsConfig{PerPage: 100},
|
|
}
|
|
_, err := provider.getDNSRecordsMap(ctx, "001")
|
|
assert.NoError(t, err)
|
|
assert.True(t, client.dnsRecordsListParams.PerPage.Present)
|
|
assert.InEpsilon(t, float64(100), client.dnsRecordsListParams.PerPage.Value, 0.0001)
|
|
})
|
|
|
|
t.Run("PerPage not set", func(t *testing.T) {
|
|
provider := &CloudFlareProvider{
|
|
Client: client,
|
|
DNSRecordsConfig: DNSRecordsConfig{},
|
|
}
|
|
_, err := provider.getDNSRecordsMap(ctx, "001")
|
|
assert.NoError(t, err)
|
|
assert.False(t, client.dnsRecordsListParams.PerPage.Present)
|
|
})
|
|
}
|
|
|
|
func TestCloudflareProvider(t *testing.T) {
|
|
var err error
|
|
|
|
type EnvVar struct {
|
|
Key string
|
|
Value string
|
|
}
|
|
|
|
// unset environment variables to avoid interference with tests
|
|
testutils.TestHelperEnvSetter(t, map[string]string{
|
|
cfAPIEmailEnvKey: "",
|
|
cfAPIKeyEnvKey: "",
|
|
cfAPITokenEnvKey: "",
|
|
})
|
|
|
|
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: cfAPITokenEnvKey, Value: "abc123def"},
|
|
},
|
|
ShouldFail: false,
|
|
},
|
|
{
|
|
Name: "use_api_token_file_contents",
|
|
Environment: []EnvVar{
|
|
{Key: cfAPITokenEnvKey, Value: tokenFile},
|
|
},
|
|
ShouldFail: false,
|
|
},
|
|
{
|
|
Name: "use_email_and_key",
|
|
Environment: []EnvVar{
|
|
{Key: cfAPIKeyEnvKey, Value: "xxxxxxxxxxxxxxxxx"},
|
|
{Key: cfAPIEmailEnvKey, 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: cfAPITokenEnvKey, Value: "file://abc"},
|
|
},
|
|
ShouldFail: true,
|
|
},
|
|
{
|
|
Name: "use_credentials_in_missing_file",
|
|
Environment: []EnvVar{
|
|
{Key: cfAPITokenEnvKey, 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 = newProvider(
|
|
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.UpdateOld = []*endpoint.Endpoint{{
|
|
DNSName: "foobar.bar.com",
|
|
Targets: endpoint.Targets{"target-old"},
|
|
}}
|
|
changes.UpdateNew = []*endpoint.Endpoint{{
|
|
DNSName: "foobar.bar.com",
|
|
Targets: endpoint.Targets{"target-new"},
|
|
}}
|
|
err := provider.ApplyChanges(t.Context(), 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: dns.RecordResponse{
|
|
ID: generateDNSRecordID("", "new.bar.com", "target"),
|
|
Name: "new.bar.com",
|
|
Content: "target",
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
{
|
|
Name: "Create",
|
|
ZoneId: "001",
|
|
RecordId: generateDNSRecordID("", "foobar.bar.com", "target-new"),
|
|
RecordData: dns.RecordResponse{
|
|
ID: generateDNSRecordID("", "foobar.bar.com", "target-new"),
|
|
Name: "foobar.bar.com",
|
|
Content: "target-new",
|
|
TTL: 1,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
// empty changes
|
|
changes.Create = []*endpoint.Endpoint{}
|
|
changes.Delete = []*endpoint.Endpoint{}
|
|
changes.UpdateOld = []*endpoint.Endpoint{}
|
|
changes.UpdateNew = []*endpoint.Endpoint{}
|
|
|
|
err = provider.ApplyChanges(t.Context(), 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(t.Context(), changes)
|
|
if err != nil {
|
|
t.Errorf("should not fail, %s", err)
|
|
}
|
|
ctx := t.Context()
|
|
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(t.Context(), 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, dns.RecordResponse{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "foobar",
|
|
}))
|
|
|
|
assert.Empty(t, p.getRecordID(recordsMap, dns.RecordResponse{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeCNAME,
|
|
Content: "fizfuz",
|
|
}))
|
|
|
|
assert.Equal(t, "1", p.getRecordID(recordsMap, dns.RecordResponse{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeCNAME,
|
|
Content: "foobar",
|
|
}))
|
|
assert.Empty(t, p.getRecordID(recordsMap, dns.RecordResponse{
|
|
Name: "bar.de",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "2.3.4.5",
|
|
}))
|
|
assert.Equal(t, "2", p.getRecordID(recordsMap, dns.RecordResponse{
|
|
Name: "bar.de",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "1.2.3.4",
|
|
}))
|
|
}
|
|
|
|
func TestCloudflareGroupByNameAndTypeWithCustomHostnames(t *testing.T) {
|
|
provider := &CloudFlareProvider{
|
|
Client: NewMockCloudFlareClient(),
|
|
domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}),
|
|
zoneIDFilter: provider.NewZoneIDFilter([]string{""}),
|
|
}
|
|
testCases := []struct {
|
|
Name string
|
|
Records []dns.RecordResponse
|
|
ExpectedEndpoints []*endpoint.Endpoint
|
|
}{
|
|
{
|
|
Name: "empty",
|
|
Records: []dns.RecordResponse{},
|
|
ExpectedEndpoints: []*endpoint.Endpoint{},
|
|
},
|
|
{
|
|
Name: "single record - single target",
|
|
Records: []dns.RecordResponse{
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.1",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
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: []dns.RecordResponse{
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.1",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.2",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
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: []dns.RecordResponse{
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.1",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.2",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
{
|
|
Name: "bar.de",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.1",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
{
|
|
Name: "bar.de",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.2",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
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: []dns.RecordResponse{
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.1",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.2",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
{
|
|
Name: "bar.de",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.1",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
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: []dns.RecordResponse{
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.1",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
{
|
|
Name: "foo.com",
|
|
Type: endpoint.RecordTypeA,
|
|
Content: "10.10.10.2",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
{
|
|
Name: "bar.de",
|
|
Type: "NOT SUPPORTED",
|
|
Content: "10.10.10.1",
|
|
TTL: defaultTTL,
|
|
Proxied: false,
|
|
},
|
|
},
|
|
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 {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
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) {
|
|
t.Parallel()
|
|
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
|
"001": {
|
|
{
|
|
ID: "mx-1",
|
|
Name: "mx.bar.com",
|
|
Type: endpoint.RecordTypeMX,
|
|
TTL: 3600,
|
|
Content: "mail.bar.com",
|
|
Priority: 10,
|
|
},
|
|
{
|
|
ID: "mx-2",
|
|
Name: "mx.bar.com",
|
|
Type: endpoint.RecordTypeMX,
|
|
TTL: 3600,
|
|
Content: "mail2.bar.com",
|
|
Priority: 20,
|
|
},
|
|
},
|
|
})
|
|
provider := &CloudFlareProvider{
|
|
Client: client,
|
|
}
|
|
ctx := t.Context()
|
|
chs := customHostnamesMap{}
|
|
records, err := provider.getDNSRecordsMap(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) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
SetupProvider func(*CloudFlareProvider)
|
|
SetupRecord func(*dns.RecordResponse)
|
|
CustomHostnames []customHostname
|
|
RegionKey string
|
|
ShouldBeUpdated bool
|
|
PropertyKey string
|
|
ExpectPropertyPresent bool
|
|
ExpectPropertyValue string
|
|
}{
|
|
{
|
|
Name: "No custom properties, ExpectUpdates: false",
|
|
SetupProvider: func(_ *CloudFlareProvider) {},
|
|
SetupRecord: func(_ *dns.RecordResponse) {},
|
|
ShouldBeUpdated: false,
|
|
},
|
|
// Proxied tests
|
|
{
|
|
Name: "ProxiedByDefault: true, ProxiedRecord: true, ExpectUpdates: false",
|
|
SetupProvider: func(p *CloudFlareProvider) { p.proxiedByDefault = true },
|
|
SetupRecord: func(r *dns.RecordResponse) { r.Proxied = true },
|
|
ShouldBeUpdated: false,
|
|
},
|
|
{
|
|
Name: "ProxiedByDefault: true, ProxiedRecord: false, ExpectUpdates: true",
|
|
SetupProvider: func(p *CloudFlareProvider) { p.proxiedByDefault = true },
|
|
SetupRecord: func(r *dns.RecordResponse) { r.Proxied = false },
|
|
ShouldBeUpdated: true,
|
|
PropertyKey: annotations.CloudflareProxiedKey,
|
|
ExpectPropertyValue: "true",
|
|
},
|
|
{
|
|
Name: "ProxiedByDefault: false, ProxiedRecord: true, ExpectUpdates: true",
|
|
SetupProvider: func(p *CloudFlareProvider) { p.proxiedByDefault = false },
|
|
SetupRecord: func(r *dns.RecordResponse) { r.Proxied = true },
|
|
ShouldBeUpdated: true,
|
|
PropertyKey: annotations.CloudflareProxiedKey,
|
|
ExpectPropertyValue: "false",
|
|
},
|
|
// Comment tests
|
|
{
|
|
Name: "DefaultComment: 'foo', RecordComment: 'foo', ExpectUpdates: false",
|
|
SetupProvider: func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = "foo" },
|
|
SetupRecord: func(r *dns.RecordResponse) { r.Comment = "foo" },
|
|
ShouldBeUpdated: false,
|
|
},
|
|
{
|
|
Name: "DefaultComment: '', RecordComment: none, ExpectUpdates: true",
|
|
SetupProvider: func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = "" },
|
|
SetupRecord: func(r *dns.RecordResponse) { r.Comment = "foo" },
|
|
ShouldBeUpdated: true,
|
|
PropertyKey: annotations.CloudflareRecordCommentKey,
|
|
ExpectPropertyPresent: false,
|
|
},
|
|
{
|
|
Name: "DefaultComment: 'foo', RecordComment: 'foo', ExpectUpdates: true",
|
|
SetupProvider: func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = "foo" },
|
|
SetupRecord: func(r *dns.RecordResponse) { r.Comment = "" },
|
|
ShouldBeUpdated: true,
|
|
PropertyKey: annotations.CloudflareRecordCommentKey,
|
|
ExpectPropertyValue: "foo",
|
|
},
|
|
// Regional Hostname tests
|
|
{
|
|
Name: "DefaultRegionKey: 'us', RecordRegionKey: 'us', ExpectUpdates: false",
|
|
SetupProvider: func(p *CloudFlareProvider) {
|
|
p.RegionalServicesConfig.Enabled = true
|
|
p.RegionalServicesConfig.RegionKey = "us"
|
|
},
|
|
RegionKey: "us",
|
|
ShouldBeUpdated: false,
|
|
},
|
|
{
|
|
Name: "DefaultRegionKey: 'us', RecordRegionKey: 'us', ExpectUpdates: false",
|
|
SetupProvider: func(p *CloudFlareProvider) {
|
|
p.RegionalServicesConfig.Enabled = true
|
|
p.RegionalServicesConfig.RegionKey = "us"
|
|
},
|
|
RegionKey: "eu",
|
|
ShouldBeUpdated: true,
|
|
PropertyKey: annotations.CloudflareRegionKey,
|
|
ExpectPropertyValue: "us",
|
|
},
|
|
// Custom Hostname tests
|
|
// TODO: add tests for custom hostnames when properly supported
|
|
}
|
|
|
|
for _, test := range testCases {
|
|
t.Run(test.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
record := dns.RecordResponse{
|
|
ID: "1234567890",
|
|
Name: "foobar.bar.com",
|
|
Type: endpoint.RecordTypeA,
|
|
TTL: 120,
|
|
Content: "1.2.3.4",
|
|
}
|
|
if test.SetupRecord != nil {
|
|
test.SetupRecord(&record)
|
|
}
|
|
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
|
"001": {record},
|
|
})
|
|
|
|
if len(test.CustomHostnames) > 0 {
|
|
customHostnames := make([]customHostname, 0, len(test.CustomHostnames))
|
|
for _, ch := range test.CustomHostnames {
|
|
ch.customOriginServer = record.Name
|
|
customHostnames = append(customHostnames, ch)
|
|
}
|
|
client.customHostnames = map[string][]customHostname{
|
|
"001": customHostnames,
|
|
}
|
|
}
|
|
|
|
if test.RegionKey != "" {
|
|
client.regionalHostnames = map[string][]regionalHostname{
|
|
"001": {{hostname: record.Name, regionKey: test.RegionKey}},
|
|
}
|
|
}
|
|
|
|
provider := &CloudFlareProvider{
|
|
Client: client,
|
|
}
|
|
if test.SetupProvider != nil {
|
|
test.SetupProvider(provider)
|
|
}
|
|
|
|
current, err := provider.Records(t.Context())
|
|
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()
|
|
require.NotNil(t, plan.Changes, "should have plan")
|
|
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.UpdateOld, 1, "should have old updates")
|
|
require.Len(t, plan.Changes.UpdateNew, 1, "should have new updates")
|
|
if test.PropertyKey != "" {
|
|
value, ok := plan.Changes.UpdateNew[0].GetProviderSpecificProperty(test.PropertyKey)
|
|
if test.ExpectPropertyPresent || test.ExpectPropertyValue != "" {
|
|
assert.Truef(t, ok, "should have property %s", test.PropertyKey)
|
|
assert.Equal(t, test.ExpectPropertyValue, value)
|
|
} else {
|
|
assert.Falsef(t, ok, "should not have property %s", test.PropertyKey)
|
|
}
|
|
} else {
|
|
assert.Empty(t, test.ExpectPropertyValue, "test misconfigured, should not expect property value if no property key set")
|
|
assert.False(t, test.ExpectPropertyPresent, "test misconfigured, should not expect property presence if no property key set")
|
|
}
|
|
} else {
|
|
assert.Empty(t, plan.Changes.UpdateNew, "should not have new updates")
|
|
assert.Empty(t, plan.Changes.UpdateOld, "should not have old updates")
|
|
assert.Empty(t, test.PropertyKey, "test misconfigured, should not expect property if no update expected")
|
|
assert.Empty(t, test.ExpectPropertyValue, "test misconfigured, should not expect property value if no update expected")
|
|
assert.False(t, test.ExpectPropertyPresent, "test misconfigured, should not expect property presence if no update expected")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCloudflareComplexUpdate(t *testing.T) {
|
|
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
|
"001": ExampleDomain,
|
|
})
|
|
|
|
provider := &CloudFlareProvider{
|
|
Client: client,
|
|
}
|
|
ctx := t.Context()
|
|
|
|
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(t.Context(), 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: "Update",
|
|
ZoneId: "001",
|
|
RecordId: "1234567890",
|
|
RecordData: dns.RecordResponse{
|
|
ID: "1234567890",
|
|
Name: "foobar.bar.com",
|
|
Type: "A",
|
|
Content: "1.2.3.4",
|
|
TTL: 1,
|
|
Proxied: true,
|
|
},
|
|
},
|
|
{
|
|
Name: "Create",
|
|
ZoneId: "001",
|
|
RecordId: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"),
|
|
RecordData: dns.RecordResponse{
|
|
ID: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"),
|
|
Name: "foobar.bar.com",
|
|
Type: "A",
|
|
Content: "2.3.4.5",
|
|
TTL: 1,
|
|
Proxied: true,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestCustomTTLWithEnabledProxyNotChanged(t *testing.T) {
|
|
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
|
"001": {
|
|
{
|
|
ID: "1234567890",
|
|
Name: "foobar.bar.com",
|
|
Type: endpoint.RecordTypeA,
|
|
TTL: 1,
|
|
Content: "1.2.3.4",
|
|
Proxied: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
provider := &CloudFlareProvider{
|
|
Client: client,
|
|
}
|
|
|
|
records, err := provider.Records(t.Context())
|
|
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) {
|
|
testutils.TestHelperEnvSetter(t, map[string]string{
|
|
cfAPITokenEnvKey: "abc123def",
|
|
cfAPIEmailEnvKey: "test@test.com",
|
|
})
|
|
provider, err := newProvider(
|
|
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) {
|
|
t.Parallel()
|
|
|
|
comment := string(make([]byte, paidZoneMaxCommentLength+1))
|
|
freeValidComment := comment[:freeZoneMaxCommentLength]
|
|
freeInvalidComment := comment[:freeZoneMaxCommentLength+1]
|
|
paidValidComment := comment[:paidZoneMaxCommentLength]
|
|
paidInvalidComment := comment[:paidZoneMaxCommentLength+1]
|
|
|
|
freeProvider := &CloudFlareProvider{
|
|
Client: NewMockCloudFlareClient(),
|
|
domainFilter: endpoint.NewDomainFilter([]string{"example.com"}),
|
|
RegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"},
|
|
}
|
|
paidProvider := &CloudFlareProvider{
|
|
Client: NewMockCloudFlareClient(),
|
|
domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}),
|
|
RegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"},
|
|
DNSRecordsConfig: DNSRecordsConfig{Comment: paidValidComment},
|
|
}
|
|
|
|
ep := &endpoint.Endpoint{
|
|
DNSName: "example.com",
|
|
RecordType: "A",
|
|
Targets: []string{"192.0.2.1"},
|
|
}
|
|
|
|
change, _ := freeProvider.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)
|
|
}
|
|
|
|
commentTestCases := []struct {
|
|
name string
|
|
provider *CloudFlareProvider
|
|
endpoint *endpoint.Endpoint
|
|
expected int
|
|
}{
|
|
{
|
|
name: "For free Zone respecting comment length, expect no trimming",
|
|
provider: freeProvider,
|
|
endpoint: &endpoint.Endpoint{
|
|
DNSName: "example.com",
|
|
RecordType: "A",
|
|
Targets: []string{"192.0.2.1"},
|
|
ProviderSpecific: endpoint.ProviderSpecific{
|
|
{
|
|
Name: annotations.CloudflareRecordCommentKey,
|
|
Value: freeValidComment,
|
|
},
|
|
},
|
|
},
|
|
expected: len(freeValidComment),
|
|
},
|
|
{
|
|
name: "For free Zones not respecting comment length, expect trimmed comments",
|
|
provider: freeProvider,
|
|
endpoint: &endpoint.Endpoint{
|
|
DNSName: "example.com",
|
|
RecordType: "A",
|
|
Targets: []string{"192.0.2.1"},
|
|
ProviderSpecific: endpoint.ProviderSpecific{
|
|
{
|
|
Name: annotations.CloudflareRecordCommentKey,
|
|
Value: freeInvalidComment,
|
|
},
|
|
},
|
|
},
|
|
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: paidValidComment,
|
|
},
|
|
},
|
|
},
|
|
expected: len(paidValidComment),
|
|
},
|
|
{
|
|
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: paidInvalidComment,
|
|
},
|
|
},
|
|
},
|
|
expected: paidZoneMaxCommentLength,
|
|
},
|
|
}
|
|
|
|
for _, test := range commentTestCases {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
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][]dns.RecordResponse{
|
|
"001": {
|
|
{
|
|
ID: "1234567890",
|
|
Name: "my-domain-here.app",
|
|
Type: endpoint.RecordTypeCNAME,
|
|
TTL: 1,
|
|
Content: "my-tunnel-guid-here.cfargotunnel.com",
|
|
Proxied: true,
|
|
},
|
|
{
|
|
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: dns.RecordResponse{
|
|
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: dns.RecordResponse{
|
|
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(t.Context(), 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][]dns.RecordResponse{
|
|
"001": {
|
|
{
|
|
ID: "1234567890",
|
|
Name: "@", // APEX record
|
|
Type: endpoint.RecordTypeCNAME,
|
|
TTL: 1,
|
|
Content: "my-tunnel-guid-here.cfargotunnel.com",
|
|
Proxied: true,
|
|
},
|
|
{
|
|
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: dns.RecordResponse{
|
|
Name: "@", // APEX record
|
|
Type: endpoint.RecordTypeCNAME,
|
|
ID: "1234567890",
|
|
Content: "my-tunnel-guid-here.cfargotunnel.com",
|
|
},
|
|
RegionalHostname: regionalHostname{
|
|
hostname: "@", // APEX record
|
|
},
|
|
},
|
|
{
|
|
Action: cloudFlareUpdate,
|
|
ResourceRecord: dns.RecordResponse{
|
|
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(t.Context(), 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]dns.RecordResponse{},
|
|
customHostnames: map[string][]customHostname{},
|
|
}
|
|
failingProvider := &CloudFlareProvider{
|
|
Client: client,
|
|
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
|
|
}
|
|
ctx := t.Context()
|
|
|
|
_, 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][]dns.RecordResponse{
|
|
"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 := logtest.LogsUnderTestWithLogLevel(log.InfoLevel, t)
|
|
p := &CloudFlareProvider{
|
|
Client: client,
|
|
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
|
|
}
|
|
ctx := t.Context()
|
|
_, err := p.Records(ctx)
|
|
if err != nil {
|
|
t.Errorf("should not fail - too long record, %s", err)
|
|
}
|
|
logtest.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 := t.Context()
|
|
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 {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
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(t.Context(), planned.Changes)
|
|
}
|
|
if e := checkFailed(tc.Name, err, tc.shouldFail); !errors.Is(e, nil) {
|
|
t.Error(e)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 := logtest.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{
|
|
UpdateNew: []*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",
|
|
},
|
|
},
|
|
}},
|
|
UpdateOld: []*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{
|
|
UpdateOld: []*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",
|
|
},
|
|
},
|
|
}},
|
|
UpdateNew: []*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 {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.customHostnamesEnabled {
|
|
provider.CustomHostnamesConfig = CustomHostnamesConfig{Enabled: true}
|
|
} else {
|
|
provider.CustomHostnamesConfig = CustomHostnamesConfig{Enabled: false}
|
|
}
|
|
hook.Reset()
|
|
err := provider.ApplyChanges(t.Context(), 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(t.Context())
|
|
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(t.Context())
|
|
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: dns.RecordResponse{Name: "test1.foo.com", Type: "A", Content: "1.2.3.4"},
|
|
},
|
|
{
|
|
Action: cloudFlareCreate,
|
|
ResourceRecord: dns.RecordResponse{Name: "test2.foo.com", Type: "A", Content: "1.2.3.5"},
|
|
},
|
|
{
|
|
Action: cloudFlareCreate,
|
|
ResourceRecord: dns.RecordResponse{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(t.Context())
|
|
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(t.Context())
|
|
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(t.Context())
|
|
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(t.Context())
|
|
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(t.Context())
|
|
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: dns.RecordResponse{Name: "api.foo.com", Type: "A", Content: "1.2.3.4"},
|
|
},
|
|
{
|
|
Action: cloudFlareUpdate,
|
|
ResourceRecord: dns.RecordResponse{Name: "www.foo.com", Type: "CNAME", Content: "foo.com"},
|
|
},
|
|
{
|
|
Action: cloudFlareCreate,
|
|
ResourceRecord: dns.RecordResponse{Name: "mail.bar.com", Type: "MX", Content: "10 mail.bar.com"},
|
|
},
|
|
{
|
|
Action: cloudFlareDelete,
|
|
ResourceRecord: dns.RecordResponse{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: newCloudflareError(429),
|
|
expectSoftError: true,
|
|
description: "CloudFlare API rate limit error should be converted to soft error",
|
|
},
|
|
{
|
|
name: "Rate limit error via ClientRateLimited",
|
|
inputError: newCloudflareError(429), // Complete rate limit error
|
|
expectSoftError: true,
|
|
description: "CloudFlare client rate limited error should be converted to soft error",
|
|
},
|
|
{
|
|
name: "Server error 500",
|
|
inputError: newCloudflareError(500),
|
|
expectSoftError: true,
|
|
description: "Server error (500+) should be converted to soft error",
|
|
},
|
|
{
|
|
name: "Server error 502",
|
|
inputError: newCloudflareError(502),
|
|
expectSoftError: true,
|
|
description: "Server error (502) should be converted to soft error",
|
|
},
|
|
{
|
|
name: "Server error 503",
|
|
inputError: newCloudflareError(503),
|
|
expectSoftError: true,
|
|
description: "Server error (503) should be converted to soft error",
|
|
},
|
|
{
|
|
name: "io.ErrUnexpectedEOF is soft",
|
|
inputError: io.ErrUnexpectedEOF,
|
|
expectSoftError: true,
|
|
description: "Unexpected EOF (connection closed mid-response) should be converted to soft error",
|
|
},
|
|
{
|
|
name: "io.EOF is soft",
|
|
inputError: io.EOF,
|
|
expectSoftError: true,
|
|
description: "EOF (connection closed before response) should be converted to soft error",
|
|
},
|
|
{
|
|
name: "wrapped io.ErrUnexpectedEOF is soft",
|
|
inputError: fmt.Errorf("transport error: %w", io.ErrUnexpectedEOF),
|
|
expectSoftError: true,
|
|
description: "Wrapped unexpected EOF 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: newCloudflareError(400),
|
|
expectSoftError: false,
|
|
description: "Client error (400) should not be converted to soft error",
|
|
},
|
|
{
|
|
name: "Client error 401",
|
|
inputError: newCloudflareError(401),
|
|
expectSoftError: false,
|
|
description: "Client error (401) should not be converted to soft error",
|
|
},
|
|
{
|
|
name: "Client error 404",
|
|
inputError: newCloudflareError(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 error message preservation for all errors now that newCloudflareError
|
|
// properly initializes the Request/Response fields
|
|
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 = newCloudflareError(429)
|
|
},
|
|
function: func(p *CloudFlareProvider) error {
|
|
p.zoneIDFilter.ZoneIDs = []string{"zone1"}
|
|
_, err := p.Zones(t.Context())
|
|
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 = newCloudflareError(500)
|
|
},
|
|
function: func(p *CloudFlareProvider) error {
|
|
p.zoneIDFilter.ZoneIDs = []string{"zone1"}
|
|
_, err := p.Zones(t.Context())
|
|
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 = newCloudflareError(404)
|
|
},
|
|
function: func(p *CloudFlareProvider) error {
|
|
p.zoneIDFilter.ZoneIDs = []string{"zone1"}
|
|
_, err := p.Zones(t.Context())
|
|
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(t.Context())
|
|
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 = newCloudflareError(503)
|
|
},
|
|
function: func(p *CloudFlareProvider) error {
|
|
_, err := p.Zones(t.Context())
|
|
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) {
|
|
// 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 := &CloudFlareProvider{
|
|
Client: NewMockCloudFlareClient(),
|
|
domainFilter: domainFilter,
|
|
}
|
|
|
|
// Capture debug logs to verify the filter log message
|
|
hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)
|
|
|
|
// Call Zones() which should trigger the domain filter logic
|
|
zones, err := p.Zones(t.Context())
|
|
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
|
|
logtest.TestHelperLogContains("zone \"foo.com\" not in domain filter", hook, t)
|
|
logtest.TestHelperLogContains("no zoneIDFilter configured, looking at all zones", hook, t)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
func TestGetUpdateDNSRecordParam(t *testing.T) {
|
|
cfc := cloudFlareChange{
|
|
ResourceRecord: dns.RecordResponse{
|
|
ID: "1234",
|
|
Name: "example.com",
|
|
Type: endpoint.RecordTypeA,
|
|
TTL: 120,
|
|
Proxied: true,
|
|
Content: "1.2.3.4",
|
|
Priority: 10,
|
|
Comment: "test-comment",
|
|
},
|
|
}
|
|
|
|
params := getUpdateDNSRecordParam("zone-123", cfc)
|
|
body := params.Body.(dns.RecordUpdateParamsBody)
|
|
|
|
assert.Equal(t, "zone-123", params.ZoneID.Value)
|
|
assert.Equal(t, "example.com", body.Name.Value)
|
|
assert.InDelta(t, 120, float64(body.TTL.Value), 0)
|
|
assert.True(t, body.Proxied.Value)
|
|
assert.EqualValues(t, "A", body.Type.Value)
|
|
assert.Equal(t, "1.2.3.4", body.Content.Value)
|
|
assert.InDelta(t, 10, float64(body.Priority.Value), 0)
|
|
assert.Equal(t, "test-comment", body.Comment.Value)
|
|
}
|
|
|
|
func TestZoneService(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
|
|
client := &zoneService{
|
|
service: cloudflare.NewClient(),
|
|
}
|
|
|
|
zoneID := "foo"
|
|
|
|
t.Run("ListDNSRecord", func(t *testing.T) {
|
|
t.Parallel()
|
|
iter := client.ListDNSRecords(ctx, dns.RecordListParams{ZoneID: cloudflare.F("foo")})
|
|
assert.False(t, iter.Next())
|
|
assert.Empty(t, iter.Current())
|
|
assert.ErrorIs(t, iter.Err(), context.Canceled)
|
|
})
|
|
|
|
t.Run("CreateDNSRecord", func(t *testing.T) {
|
|
t.Parallel()
|
|
params := getCreateDNSRecordParam(zoneID, &cloudFlareChange{})
|
|
record, err := client.CreateDNSRecord(ctx, params)
|
|
assert.Empty(t, record)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
|
|
t.Run("UpdateDNSRecord", func(t *testing.T) {
|
|
t.Parallel()
|
|
recordParam := getUpdateDNSRecordParam(zoneID, cloudFlareChange{})
|
|
_, err := client.UpdateDNSRecord(ctx, "1234", recordParam)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
|
|
t.Run("DeleteDNSRecord", func(t *testing.T) {
|
|
t.Parallel()
|
|
err := client.DeleteDNSRecord(ctx, "1234", dns.RecordDeleteParams{ZoneID: cloudflare.F("foo")})
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
|
|
t.Run("ListZones", func(t *testing.T) {
|
|
t.Parallel()
|
|
iter := client.ListZones(ctx, listZonesV4Params())
|
|
assert.False(t, iter.Next())
|
|
assert.Empty(t, iter.Current())
|
|
assert.ErrorIs(t, iter.Err(), context.Canceled)
|
|
})
|
|
|
|
t.Run("GetZone", func(t *testing.T) {
|
|
t.Parallel()
|
|
zone, err := client.GetZone(ctx, zoneID)
|
|
assert.Nil(t, zone)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
|
|
t.Run("ListDataLocalizationRegionalHostnames", func(t *testing.T) {
|
|
t.Parallel()
|
|
params := listDataLocalizationRegionalHostnamesParams(zoneID)
|
|
iter := client.ListDataLocalizationRegionalHostnames(ctx, params)
|
|
assert.False(t, iter.Next())
|
|
assert.Empty(t, iter.Current())
|
|
assert.ErrorIs(t, iter.Err(), context.Canceled)
|
|
})
|
|
|
|
t.Run("CreateDataLocalizationRegionalHostname", func(t *testing.T) {
|
|
t.Parallel()
|
|
params := createDataLocalizationRegionalHostnameParams(zoneID, regionalHostnameChange{})
|
|
err := client.CreateDataLocalizationRegionalHostname(ctx, params)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
|
|
t.Run("DeleteDataLocalizationRegionalHostname", func(t *testing.T) {
|
|
t.Parallel()
|
|
params := deleteDataLocalizationRegionalHostnameParams(zoneID)
|
|
err := client.DeleteDataLocalizationRegionalHostname(ctx, "foo", params)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
|
|
t.Run("UpdateDataLocalizationRegionalHostname", func(t *testing.T) {
|
|
t.Parallel()
|
|
params := updateDataLocalizationRegionalHostnameParams(zoneID, regionalHostnameChange{})
|
|
err := client.UpdateDataLocalizationRegionalHostname(ctx, "foo", params)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
|
|
t.Run("CustomHostnames", func(t *testing.T) {
|
|
t.Parallel()
|
|
iter := client.CustomHostnames(ctx, zoneID)
|
|
assert.False(t, iter.Next())
|
|
assert.Empty(t, iter.Current())
|
|
assert.ErrorIs(t, iter.Err(), context.Canceled)
|
|
})
|
|
|
|
t.Run("CreateCustomHostname", func(t *testing.T) {
|
|
t.Parallel()
|
|
err := client.CreateCustomHostname(ctx, zoneID, customHostname{})
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
|
|
t.Run("BatchDNSRecords", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := client.BatchDNSRecords(ctx, dns.RecordBatchParams{ZoneID: cloudflare.F(zoneID)})
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
})
|
|
}
|
|
|
|
func TestSubmitChanges_ErrorPaths(t *testing.T) {
|
|
t.Run("getDNSRecordsMap error returns error from submitChanges", func(t *testing.T) {
|
|
client := NewMockCloudFlareClient()
|
|
client.dnsRecordsError = errors.New("dns list failed")
|
|
p := &CloudFlareProvider{Client: client}
|
|
|
|
changes := &plan.Changes{
|
|
Create: []*endpoint.Endpoint{
|
|
{DNSName: "test.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"},
|
|
},
|
|
}
|
|
err := p.ApplyChanges(t.Context(), changes)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "could not fetch records from zone")
|
|
})
|
|
|
|
t.Run("listCustomHostnamesWithPagination error returns error from submitChanges", func(t *testing.T) {
|
|
// The mock returns an error for CustomHostnames() when zoneID starts with "newerror-".
|
|
// CustomHostnamesConfig.Enabled must be true to reach that code path.
|
|
client := &mockCloudFlareClient{
|
|
Zones: map[string]string{
|
|
"newerror-zone1": "errorcf.com",
|
|
},
|
|
Records: map[string]map[string]dns.RecordResponse{
|
|
"newerror-zone1": {},
|
|
},
|
|
customHostnames: map[string][]customHostname{},
|
|
regionalHostnames: map[string][]regionalHostname{},
|
|
}
|
|
p := &CloudFlareProvider{
|
|
Client: client,
|
|
domainFilter: endpoint.NewDomainFilter([]string{"errorcf.com"}),
|
|
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
|
|
}
|
|
|
|
changes := &plan.Changes{
|
|
Create: []*endpoint.Endpoint{
|
|
{DNSName: "sub.errorcf.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"},
|
|
},
|
|
}
|
|
err := p.ApplyChanges(t.Context(), changes)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "could not fetch custom hostnames from zone")
|
|
})
|
|
|
|
t.Run("processCustomHostnameChanges failure sets failedChange", func(t *testing.T) {
|
|
// The mock's CreateCustomHostname fails for "newerror-create.foo.fancybar.com".
|
|
// With CustomHostnames enabled, the failing create causes processCustomHostnameChanges
|
|
// to return true, which sets failedChange=true for the zone.
|
|
client := NewMockCloudFlareClient()
|
|
p := &CloudFlareProvider{
|
|
Client: client,
|
|
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
|
|
}
|
|
|
|
changes := &plan.Changes{
|
|
Create: []*endpoint.Endpoint{
|
|
{
|
|
DNSName: "a.bar.com",
|
|
Targets: endpoint.Targets{"1.2.3.4"},
|
|
RecordType: "A",
|
|
ProviderSpecific: endpoint.ProviderSpecific{
|
|
{
|
|
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
|
|
Value: "newerror-create.foo.fancybar.com",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
err := p.ApplyChanges(t.Context(), changes)
|
|
require.Error(t, err, "failing custom hostname create should cause an error")
|
|
})
|
|
|
|
t.Run("Zones error propagates from submitChanges", func(t *testing.T) {
|
|
// Setting listZonesError causes p.Zones() to fail inside submitChanges,
|
|
// exercising the `if err != nil { return err }` block at the top of the loop.
|
|
client := NewMockCloudFlareClient()
|
|
client.listZonesError = errors.New("zones fetch failed")
|
|
p := &CloudFlareProvider{Client: client}
|
|
|
|
changes := &plan.Changes{
|
|
Create: []*endpoint.Endpoint{
|
|
{DNSName: "test.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"},
|
|
},
|
|
}
|
|
err := p.ApplyChanges(t.Context(), changes)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "zones fetch failed")
|
|
})
|
|
}
|
|
|
|
func TestParseTagsAnnotation(t *testing.T) {
|
|
t.Run("parses comma-separated tags", func(t *testing.T) {
|
|
tags := parseTagsAnnotation("tag1,tag2,tag3")
|
|
assert.Equal(t, []string{"tag1", "tag2", "tag3"}, tags)
|
|
})
|
|
t.Run("trims whitespace from each tag", func(t *testing.T) {
|
|
tags := parseTagsAnnotation(" z-tag , a-tag ")
|
|
assert.Equal(t, []string{"a-tag", "z-tag"}, tags)
|
|
})
|
|
t.Run("sorts tags canonically", func(t *testing.T) {
|
|
tags := parseTagsAnnotation("c,a,b")
|
|
assert.Equal(t, []string{"a", "b", "c"}, tags)
|
|
})
|
|
t.Run("skips empty tokens", func(t *testing.T) {
|
|
tags := parseTagsAnnotation("tag1,,,, tag2")
|
|
assert.Equal(t, []string{"tag1", "tag2"}, tags)
|
|
})
|
|
}
|
|
|
|
func TestAdjustEndpoints_TagsAnnotation(t *testing.T) {
|
|
// parseTagsAnnotation is only invoked when the CloudflareTagsKey annotation
|
|
// is present on the endpoint. This test exercises that branch via AdjustEndpoints.
|
|
p := &CloudFlareProvider{}
|
|
ep := &endpoint.Endpoint{
|
|
RecordType: "A",
|
|
DNSName: "test.bar.com",
|
|
Targets: endpoint.Targets{"1.2.3.4"},
|
|
ProviderSpecific: endpoint.ProviderSpecific{
|
|
{
|
|
Name: annotations.CloudflareTagsKey,
|
|
Value: "beta, alpha, gamma",
|
|
},
|
|
},
|
|
}
|
|
adjusted, err := p.AdjustEndpoints([]*endpoint.Endpoint{ep})
|
|
require.NoError(t, err)
|
|
require.Len(t, adjusted, 1)
|
|
|
|
val, ok := adjusted[0].GetProviderSpecificProperty(annotations.CloudflareTagsKey)
|
|
require.True(t, ok, "tags annotation should still be present after AdjustEndpoints")
|
|
// Tags should be sorted and whitespace-trimmed
|
|
assert.Equal(t, "alpha,beta,gamma", val)
|
|
}
|
|
|
|
func TestZoneServiceZoneIDByName(t *testing.T) {
|
|
// Build a minimal cloudflare API response page for /zones.
|
|
writeZonesPage := func(w http.ResponseWriter, zones []map[string]any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
|
"result": zones,
|
|
"result_info": map[string]any{
|
|
"count": len(zones),
|
|
"total_count": len(zones),
|
|
"page": 1,
|
|
"per_page": 20,
|
|
},
|
|
"success": true,
|
|
"errors": []any{},
|
|
"messages": []any{},
|
|
}); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
t.Run("zone found returns its ID", func(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
writeZonesPage(w, []map[string]any{
|
|
{"id": "zone-abc", "name": "example.com", "plan": map[string]any{"is_subscribed": false}},
|
|
})
|
|
}))
|
|
defer ts.Close()
|
|
|
|
svc := &zoneService{service: cloudflare.NewClient(
|
|
option.WithBaseURL(ts.URL+"/"),
|
|
option.WithAPIToken("test-token"),
|
|
option.WithMaxRetries(0),
|
|
)}
|
|
id, err := svc.ZoneIDByName("example.com")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "zone-abc", id)
|
|
})
|
|
|
|
t.Run("zone not found returns descriptive error", func(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
writeZonesPage(w, []map[string]any{})
|
|
}))
|
|
defer ts.Close()
|
|
|
|
svc := &zoneService{service: cloudflare.NewClient(
|
|
option.WithBaseURL(ts.URL+"/"),
|
|
option.WithAPIToken("test-token"),
|
|
option.WithMaxRetries(0),
|
|
)}
|
|
id, err := svc.ZoneIDByName("missing.com")
|
|
assert.Empty(t, id)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not found in CloudFlare account")
|
|
})
|
|
|
|
t.Run("server error causes wrapped iterator error", func(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"result": nil,
|
|
"success": false,
|
|
"errors": []map[string]any{{"code": 500, "message": "internal server error"}},
|
|
"messages": []any{},
|
|
})
|
|
}))
|
|
defer ts.Close()
|
|
|
|
svc := &zoneService{service: cloudflare.NewClient(
|
|
option.WithBaseURL(ts.URL+"/"),
|
|
option.WithAPIToken("test-token"),
|
|
option.WithMaxRetries(0),
|
|
)}
|
|
id, err := svc.ZoneIDByName("any.com")
|
|
assert.Empty(t, id)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "failed to list zones from CloudFlare API")
|
|
})
|
|
}
|