external-dns/endpoint/endpoint_test.go
Arthur Le Roux e17b5ed07b
feat(cloudflare): add support for MX records (#5283)
* feat(cloudflare): add support for MX records

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* test(txt): add additional TXT and MX record test cases

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* feat(endpoint): implement parsing for MX and SRV records with structured targets

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(txt): remove TXT record type from supported types in NewTXTRegistry

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(digitalocean): streamline MX record handling

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(cloudflare): improve error handling in change creation

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(endpoint): return all parsed SRV targets instead of a single target

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* test(endpoint): add parsing tests for MX and SRV records

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(endpoint): streamline MX and SRV record validation and parsing

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(digital_ocean): simplify MX record parsing

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(docs): update link to CRD source in MX record documentation

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(cloudflare): improve error handling for MX record parsing

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(cloudflare): improve error message formatting for MX record parsing

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(endpoint): rename ParseMXRecord to NewMXTarget and update references

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(endpoint): update NewMXTarget to return pointer and adjust tests accordingly

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(cloudflare): consolidate proxyEnabled and proxyDisabled variable declarations

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(endpoint): update TestNewMXTarget to reflect changes in MXTarget struct fields and add missing test case for host validation

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(digitalocean): improve MX record handling by adjusting error handling and ensuring proper priority and host retrieval

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(endpoint): change MXTarget fields to unexported and update NewMXTarget to use them

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(cloudflare): update groupByNameAndTypeWithCustomHostnames to use provider methods and enhance MX record handling in tests

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* test(cloudflare): enhance test cover

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(endpoint): remove unused SRVTarget struct from endpoint.go

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(endpoint): rename NewMXTarget to NewMXRecord for clarity and update references

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* Update docs/sources/mx-record.md

Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>

---------

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>
Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>
2025-06-23 23:32:28 -07:00

928 lines
19 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 endpoint
import (
"fmt"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewEndpoint(t *testing.T) {
e := NewEndpoint("example.org", "CNAME", "foo.com")
if e.DNSName != "example.org" || e.Targets[0] != "foo.com" || e.RecordType != "CNAME" {
t.Error("endpoint is not initialized correctly")
}
if e.Labels == nil {
t.Error("Labels is not initialized")
}
w := NewEndpoint("example.org.", "", "load-balancer.com.")
if w.DNSName != "example.org" || w.Targets[0] != "load-balancer.com" || w.RecordType != "" {
t.Error("endpoint is not initialized correctly")
}
}
func TestNewTargets(t *testing.T) {
cases := []struct {
name string
input []string
expected Targets
}{
{
name: "no targets",
input: []string{},
expected: Targets{},
},
{
name: "single target",
input: []string{"1.2.3.4"},
expected: Targets{"1.2.3.4"},
},
{
name: "multiple targets",
input: []string{"example.com", "8.8.8.8", "::0001"},
expected: Targets{"example.com", "8.8.8.8", "::0001"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
Targets := NewTargets(c.input...)
changedTarget := Targets.String()
assert.Equal(t, c.expected.String(), changedTarget)
})
}
}
func TestTargetsSame(t *testing.T) {
tests := []Targets{
{""},
{"1.2.3.4"},
{"8.8.8.8", "8.8.4.4"},
{"dd:dd::01", "::1", "::0001"},
{"example.org", "EXAMPLE.ORG"},
}
for _, d := range tests {
if d.Same(d) != true {
t.Errorf("%#v should equal %#v", d, d)
}
}
}
func TestSameSuccess(t *testing.T) {
tests := []struct {
a Targets
b Targets
}{
{
[]string{"::1"},
[]string{"::0001"},
},
{
[]string{"::1", "dd:dd::01"},
[]string{"dd:00dd::0001", "::0001"},
},
{
[]string{"::1", "dd:dd::01"},
[]string{"00dd:dd::0001", "::0001"},
},
{
[]string{"::1", "1.1.1.1", "2600.com", "3.3.3.3"},
[]string{"2600.com", "::0001", "3.3.3.3", "1.1.1.1"},
},
}
for _, d := range tests {
if d.a.Same(d.b) == false {
t.Errorf("%#v should equal %#v", d.a, d.b)
}
}
}
func TestSameFailures(t *testing.T) {
tests := []struct {
a Targets
b Targets
}{
{
[]string{"1.2.3.4"},
[]string{"4.3.2.1"},
}, {
[]string{"1.2.3.4"},
[]string{"1.2.3.4", "4.3.2.1"},
}, {
[]string{"1.2.3.4", "4.3.2.1"},
[]string{"1.2.3.4"},
}, {
[]string{"1.2.3.4", "4.3.2.1"},
[]string{"8.8.8.8", "8.8.4.4"},
},
{
[]string{"::1", "2600.com", "3.3.3.3"},
[]string{"2600.com", "3.3.3.3", "1.1.1.1"},
},
}
for _, d := range tests {
if d.a.Same(d.b) == true {
t.Errorf("%#v should not equal %#v", d.a, d.b)
}
}
}
func TestIsLess(t *testing.T) {
testsA := []Targets{
{""},
{"1.2.3.4"},
{"1.2.3.4"},
{"example.org", "example.com"},
{"8.8.8.8", "8.8.4.4"},
{"1-2-3-4.example.org", "EXAMPLE.ORG"},
{"1-2-3-4.example.org", "EXAMPLE.ORG", "1.2.3.4"},
{"example.com", "example.org"},
}
testsB := []Targets{
{"", ""},
{"1-2-3-4.example.org"},
{"1.2.3.5"},
{"example.com", "examplea.org"},
{"8.8.8.8"},
{"1.2.3.4", "EXAMPLE.ORG"},
{"1-2-3-4.example.org", "EXAMPLE.ORG"},
{"example.com", "example.org"},
}
expected := []bool{
true,
true,
true,
true,
false,
false,
false,
false,
}
for i, d := range testsA {
if d.IsLess(testsB[i]) != expected[i] {
t.Errorf("%v < %v is expected to be %v", d, testsB[i], expected[i])
}
}
}
func TestGetProviderSpecificProperty(t *testing.T) {
e := &Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{
Name: "name",
Value: "value",
},
},
}
t.Run("key is not present in provider specific", func(t *testing.T) {
val, ok := e.GetProviderSpecificProperty("hello")
assert.False(t, ok)
assert.Empty(t, val)
})
t.Run("key is present in provider specific", func(t *testing.T) {
val, ok := e.GetProviderSpecificProperty("name")
assert.True(t, ok)
assert.NotEmpty(t, val)
})
}
func TestSetProviderSpecficProperty(t *testing.T) {
cases := []struct {
name string
endpoint Endpoint
key string
value string
expectedIdentifier string
expected []ProviderSpecificProperty
}{
{
name: "endpoint is empty",
endpoint: Endpoint{},
key: "key1",
value: "value1",
expected: []ProviderSpecificProperty{
{
Name: "key1",
Value: "value1",
},
},
},
{
name: "name and key are not matching",
endpoint: Endpoint{
DNSName: "example.org",
RecordTTL: TTL(0),
RecordType: RecordTypeA,
SetIdentifier: "newIdentifier",
Targets: Targets{
"example.org", "example.com", "1.2.4.5",
},
ProviderSpecific: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
},
},
expectedIdentifier: "newIdentifier",
key: "name2",
value: "value2",
expected: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
{
Name: "name2",
Value: "value2",
},
},
},
{
name: "some keys are matching and some are not matching ",
endpoint: Endpoint{
DNSName: "example.org",
RecordTTL: TTL(0),
RecordType: RecordTypeA,
SetIdentifier: "newIdentifier",
Targets: Targets{
"example.org", "example.com", "1.2.4.5",
},
ProviderSpecific: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
{
Name: "name2",
Value: "value2",
},
{
Name: "name3",
Value: "value3",
},
},
},
key: "name2",
value: "value2",
expectedIdentifier: "newIdentifier",
expected: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
{
Name: "name2",
Value: "value2",
},
{
Name: "name3",
Value: "value3",
},
},
},
{
name: "name and key are not matching",
endpoint: Endpoint{
DNSName: "example.org",
RecordTTL: TTL(0),
RecordType: RecordTypeA,
SetIdentifier: "identifier",
Targets: Targets{
"example.org", "example.com", "1.2.4.5",
},
ProviderSpecific: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
},
},
key: "name1",
value: "value2",
expectedIdentifier: "identifier",
expected: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value2",
},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.endpoint.WithProviderSpecific(c.key, c.value)
expectedString := fmt.Sprintf("%s %d IN %s %s %s %s", c.endpoint.DNSName, c.endpoint.RecordTTL, c.endpoint.RecordType, c.endpoint.SetIdentifier, c.endpoint.Targets, c.endpoint.ProviderSpecific)
identifier := c.endpoint.WithSetIdentifier(c.endpoint.SetIdentifier)
assert.Equal(t, c.expectedIdentifier, identifier.SetIdentifier)
assert.Equal(t, expectedString, c.endpoint.String())
if !reflect.DeepEqual([]ProviderSpecificProperty(c.endpoint.ProviderSpecific), c.expected) {
t.Errorf("unexpected ProviderSpecific:\nGot: %#v\nExpected: %#v", c.endpoint.ProviderSpecific, c.expected)
}
})
}
}
func TestDeleteProviderSpecificProperty(t *testing.T) {
cases := []struct {
name string
endpoint Endpoint
key string
expected []ProviderSpecificProperty
}{
{
name: "name and key are not matching",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
},
},
key: "name2",
expected: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
},
},
{
name: "some keys are matching and some keys are not matching",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
{
Name: "name2",
Value: "value2",
},
{
Name: "name3",
Value: "value3",
},
},
},
key: "name2",
expected: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
{
Name: "name3",
Value: "value3",
},
},
},
{
name: "name and key are matching",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{
Name: "name1",
Value: "value1",
},
},
},
key: "name1",
expected: []ProviderSpecificProperty{},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.endpoint.DeleteProviderSpecificProperty(c.key)
if !reflect.DeepEqual([]ProviderSpecificProperty(c.endpoint.ProviderSpecific), c.expected) {
t.Errorf("unexpected ProviderSpecific:\nGot: %#v\nExpected: %#v", c.endpoint.ProviderSpecific, c.expected)
}
})
}
}
func TestFilterEndpointsByOwnerIDWithRecordTypeA(t *testing.T) {
foo1 := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
foo2 := &Endpoint{
DNSName: "foo2.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
bar := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "bar",
},
}
type args struct {
ownerID string
eps []*Endpoint
}
tests := []struct {
name string
args args
want []*Endpoint
}{
{
name: "filter values",
args: args{
ownerID: "foo",
eps: []*Endpoint{
foo1,
foo2,
bar,
},
},
want: []*Endpoint{
foo1,
foo2,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FilterEndpointsByOwnerID(tt.args.ownerID, tt.args.eps); !reflect.DeepEqual(got, tt.want) {
t.Errorf("ApplyEndpointFilter() = %v, want %v", got, tt.want)
}
})
}
}
func TestFilterEndpointsByOwnerIDWithRecordTypeCNAME(t *testing.T) {
foo1 := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeCNAME,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
foo2 := &Endpoint{
DNSName: "foo2.com",
RecordType: RecordTypeCNAME,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
bar := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeCNAME,
Labels: Labels{
OwnerLabelKey: "bar",
},
}
type args struct {
ownerID string
eps []*Endpoint
}
tests := []struct {
name string
args args
want []*Endpoint
}{
{
name: "filter values",
args: args{
ownerID: "foo",
eps: []*Endpoint{
foo1,
foo2,
bar,
},
},
want: []*Endpoint{
foo1,
foo2,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FilterEndpointsByOwnerID(tt.args.ownerID, tt.args.eps); !reflect.DeepEqual(got, tt.want) {
t.Errorf("ApplyEndpointFilter() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsOwnedBy(t *testing.T) {
type fields struct {
Labels Labels
}
type args struct {
ownerID string
}
tests := []struct {
name string
fields fields
args args
want bool
}{
{
name: "empty labels",
fields: fields{Labels: Labels{}},
args: args{ownerID: "foo"},
want: false,
},
{
name: "owner label not match",
fields: fields{Labels: Labels{OwnerLabelKey: "bar"}},
args: args{ownerID: "foo"},
want: false,
},
{
name: "owner label match",
fields: fields{Labels: Labels{OwnerLabelKey: "foo"}},
args: args{ownerID: "foo"},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &Endpoint{
Labels: tt.fields.Labels,
}
if got := e.IsOwnedBy(tt.args.ownerID); got != tt.want {
t.Errorf("Endpoint.IsOwnedBy() = %v, want %v", got, tt.want)
}
})
}
}
func TestDuplicatedEndpointsWithSimpleZone(t *testing.T) {
foo1 := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
foo2 := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
bar := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "bar",
},
}
type args struct {
eps []*Endpoint
}
tests := []struct {
name string
args args
want []*Endpoint
}{
{
name: "filter values",
args: args{
eps: []*Endpoint{
foo1,
foo2,
bar,
},
},
want: []*Endpoint{
foo1,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := RemoveDuplicates(tt.args.eps); !reflect.DeepEqual(got, tt.want) {
t.Errorf("RemoveDuplicates() = %v, want %v", got, tt.want)
}
})
}
}
func TestDuplicatedEndpointsWithOverlappingZones(t *testing.T) {
foo1 := &Endpoint{
DNSName: "internal.foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
foo2 := &Endpoint{
DNSName: "internal.foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
foo3 := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
foo4 := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "foo",
},
}
bar := &Endpoint{
DNSName: "internal.foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "bar",
},
}
bar2 := &Endpoint{
DNSName: "foo.com",
RecordType: RecordTypeA,
Labels: Labels{
OwnerLabelKey: "bar",
},
}
type args struct {
eps []*Endpoint
}
tests := []struct {
name string
args args
want []*Endpoint
}{
{
name: "filter values",
args: args{
eps: []*Endpoint{
foo1,
foo2,
foo3,
foo4,
bar,
bar2,
},
},
want: []*Endpoint{
foo1,
foo3,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := RemoveDuplicates(tt.args.eps); !reflect.DeepEqual(got, tt.want) {
t.Errorf("RemoveDuplicates() = %v, want %v", got, tt.want)
}
})
}
}
func TestPDNScheckEndpoint(t *testing.T) {
tests := []struct {
description string
endpoint Endpoint
expected bool
}{
{
description: "Valid MX record target",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeMX,
Targets: Targets{"10 example.com"},
},
expected: true,
},
{
description: "Valid MX record with multiple targets",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeMX,
Targets: Targets{"10 example.com", "20 backup.example.com"},
},
expected: true,
},
{
description: "MX record with valid and invalid targets",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeMX,
Targets: Targets{"example.com", "backup.example.com"},
},
expected: false,
},
{
description: "Invalid MX record with missing priority value",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeMX,
Targets: Targets{"example.com"},
},
expected: false,
},
{
description: "Invalid MX record with too many arguments",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeMX,
Targets: Targets{"10 example.com abc"},
},
expected: false,
},
{
description: "Invalid MX record with non-integer priority",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeMX,
Targets: Targets{"abc example.com"},
},
expected: false,
},
{
description: "Valid SRV record target",
endpoint: Endpoint{
DNSName: "_service._tls.example.com",
RecordType: RecordTypeSRV,
Targets: Targets{"10 20 5060 service.example.com"},
},
expected: true,
},
{
description: "Invalid SRV record with missing part",
endpoint: Endpoint{
DNSName: "_service._tls.example.com",
RecordType: RecordTypeSRV,
Targets: Targets{"10 20 5060"},
},
expected: false,
},
{
description: "Invalid SRV record with non-integer part",
endpoint: Endpoint{
DNSName: "_service._tls.example.com",
RecordType: RecordTypeSRV,
Targets: Targets{"10 20 abc service.example.com"},
},
expected: false,
},
}
for _, tt := range tests {
actual := tt.endpoint.CheckEndpoint()
assert.Equal(t, tt.expected, actual)
}
}
func TestNewMXTarget(t *testing.T) {
tests := []struct {
description string
target string
expected *MXTarget
expectError bool
}{
{
description: "Valid MX record",
target: "10 example.com",
expected: &MXTarget{priority: 10, host: "example.com"},
expectError: false,
},
{
description: "Invalid MX record with missing priority",
target: "example.com",
expectError: true,
},
{
description: "Invalid MX record with non-integer priority",
target: "abc example.com",
expectError: true,
},
{
description: "Invalid MX record with too many parts",
target: "10 example.com extra",
expectError: true,
},
{
description: "Missing host",
target: "10 ",
expected: nil,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
actual, err := NewMXRecord(tt.target)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, actual)
}
})
}
}
func TestCheckEndpoint(t *testing.T) {
tests := []struct {
description string
endpoint Endpoint
expected bool
}{
{
description: "Valid MX record target",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeMX,
Targets: Targets{"10 example.com"},
},
expected: true,
},
{
description: "Invalid MX record target",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeMX,
Targets: Targets{"example.com"},
},
expected: false,
},
{
description: "Valid SRV record target",
endpoint: Endpoint{
DNSName: "_service._tcp.example.com",
RecordType: RecordTypeSRV,
Targets: Targets{"10 5 5060 example.com"},
},
expected: true,
},
{
description: "Invalid SRV record target",
endpoint: Endpoint{
DNSName: "_service._tcp.example.com",
RecordType: RecordTypeSRV,
Targets: Targets{"10 5 example.com"},
},
expected: false,
},
{
description: "Non-MX/SRV record type",
endpoint: Endpoint{
DNSName: "example.com",
RecordType: RecordTypeA,
Targets: Targets{"192.168.1.1"},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
actual := tt.endpoint.CheckEndpoint()
assert.Equal(t, tt.expected, actual)
})
}
}