sigs.k8s.io/external-dns@v0.14.1/provider/digitalocean/digital_ocean_test.go (about)

     1  /*
     2  Copyright 2017 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package digitalocean
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"os"
    23  	"reflect"
    24  	"sort"
    25  	"testing"
    26  
    27  	"github.com/digitalocean/godo"
    28  	"github.com/google/go-cmp/cmp"
    29  	"github.com/stretchr/testify/assert"
    30  	"github.com/stretchr/testify/require"
    31  
    32  	"sigs.k8s.io/external-dns/endpoint"
    33  	"sigs.k8s.io/external-dns/plan"
    34  )
    35  
    36  type mockDigitalOceanClient struct{}
    37  
    38  func (m *mockDigitalOceanClient) RecordsByName(context.Context, string, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) {
    39  	// not used, here only to correctly implement the interface
    40  	return nil, nil, nil
    41  }
    42  
    43  func (m *mockDigitalOceanClient) RecordsByTypeAndName(context.Context, string, string, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) {
    44  	// not used, here only to correctly implement the interface
    45  	return nil, nil, nil
    46  }
    47  
    48  func (m *mockDigitalOceanClient) RecordsByType(context.Context, string, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) {
    49  	// not used, here only to correctly implement the interface
    50  	return nil, nil, nil
    51  }
    52  
    53  func (m *mockDigitalOceanClient) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Domain, *godo.Response, error) {
    54  	if opt == nil || opt.Page == 0 {
    55  		return []godo.Domain{{Name: "foo.com"}, {Name: "example.com"}}, &godo.Response{
    56  			Links: &godo.Links{
    57  				Pages: &godo.Pages{
    58  					Next: "http://example.com/v2/domains/?page=2",
    59  					Last: "1234",
    60  				},
    61  			},
    62  		}, nil
    63  	}
    64  	return []godo.Domain{{Name: "bar.com"}, {Name: "bar.de"}}, nil, nil
    65  }
    66  
    67  func (m *mockDigitalOceanClient) Create(context.Context, *godo.DomainCreateRequest) (*godo.Domain, *godo.Response, error) {
    68  	return &godo.Domain{Name: "example.com"}, nil, nil
    69  }
    70  
    71  func (m *mockDigitalOceanClient) CreateRecord(context.Context, string, *godo.DomainRecordEditRequest) (*godo.DomainRecord, *godo.Response, error) {
    72  	return &godo.DomainRecord{ID: 1, Name: "new", Type: "CNAME"}, nil, nil
    73  }
    74  
    75  func (m *mockDigitalOceanClient) Delete(context.Context, string) (*godo.Response, error) {
    76  	return nil, nil
    77  }
    78  
    79  func (m *mockDigitalOceanClient) DeleteRecord(ctx context.Context, domain string, id int) (*godo.Response, error) {
    80  	return nil, nil
    81  }
    82  
    83  func (m *mockDigitalOceanClient) EditRecord(ctx context.Context, domain string, id int, editRequest *godo.DomainRecordEditRequest) (*godo.DomainRecord, *godo.Response, error) {
    84  	return &godo.DomainRecord{ID: 1}, nil, nil
    85  }
    86  
    87  func (m *mockDigitalOceanClient) Get(ctx context.Context, name string) (*godo.Domain, *godo.Response, error) {
    88  	return &godo.Domain{Name: "example.com"}, nil, nil
    89  }
    90  
    91  func (m *mockDigitalOceanClient) Record(ctx context.Context, domain string, id int) (*godo.DomainRecord, *godo.Response, error) {
    92  	return &godo.DomainRecord{ID: 1}, nil, nil
    93  }
    94  
    95  func (m *mockDigitalOceanClient) Records(ctx context.Context, domain string, opt *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) {
    96  	switch domain {
    97  	case "foo.com":
    98  		if opt == nil || opt.Page == 0 {
    99  			return []godo.DomainRecord{
   100  					{ID: 1, Name: "foo.ext-dns-test", Type: "CNAME"},
   101  					{ID: 2, Name: "bar.ext-dns-test", Type: "CNAME"},
   102  					{ID: 3, Name: "@", Type: endpoint.RecordTypeCNAME},
   103  				}, &godo.Response{
   104  					Links: &godo.Links{
   105  						Pages: &godo.Pages{
   106  							Next: "http://example.com/v2/domains/?page=2",
   107  							Last: "1234",
   108  						},
   109  					},
   110  				}, nil
   111  		}
   112  		return []godo.DomainRecord{{ID: 3, Name: "baz.ext-dns-test", Type: "A"}}, nil, nil
   113  	case "example.com":
   114  		if opt == nil || opt.Page == 0 {
   115  			return []godo.DomainRecord{{ID: 1, Name: "new", Type: "CNAME"}}, &godo.Response{
   116  				Links: &godo.Links{
   117  					Pages: &godo.Pages{
   118  						Next: "http://example.com/v2/domains/?page=2",
   119  						Last: "1234",
   120  					},
   121  				},
   122  			}, nil
   123  		}
   124  		return nil, nil, nil
   125  	default:
   126  		return nil, nil, nil
   127  	}
   128  }
   129  
   130  type mockDigitalOceanRecordsFail struct{}
   131  
   132  func (m *mockDigitalOceanRecordsFail) RecordsByName(context.Context, string, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) {
   133  	// not used, here only to correctly implement the interface
   134  	return nil, nil, nil
   135  }
   136  
   137  func (m *mockDigitalOceanRecordsFail) RecordsByTypeAndName(context.Context, string, string, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) {
   138  	// not used, here only to correctly implement the interface
   139  	return nil, nil, nil
   140  }
   141  
   142  func (m *mockDigitalOceanRecordsFail) RecordsByType(context.Context, string, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) {
   143  	// not used, here only to correctly implement the interface
   144  	return nil, nil, nil
   145  }
   146  
   147  func (m *mockDigitalOceanRecordsFail) List(context.Context, *godo.ListOptions) ([]godo.Domain, *godo.Response, error) {
   148  	return []godo.Domain{{Name: "foo.com"}, {Name: "bar.com"}}, nil, nil
   149  }
   150  
   151  func (m *mockDigitalOceanRecordsFail) Create(context.Context, *godo.DomainCreateRequest) (*godo.Domain, *godo.Response, error) {
   152  	return &godo.Domain{Name: "example.com"}, nil, nil
   153  }
   154  
   155  func (m *mockDigitalOceanRecordsFail) CreateRecord(context.Context, string, *godo.DomainRecordEditRequest) (*godo.DomainRecord, *godo.Response, error) {
   156  	return &godo.DomainRecord{ID: 1}, nil, nil
   157  }
   158  
   159  func (m *mockDigitalOceanRecordsFail) Delete(context.Context, string) (*godo.Response, error) {
   160  	return nil, nil
   161  }
   162  
   163  func (m *mockDigitalOceanRecordsFail) DeleteRecord(ctx context.Context, domain string, id int) (*godo.Response, error) {
   164  	return nil, nil
   165  }
   166  
   167  func (m *mockDigitalOceanRecordsFail) EditRecord(ctx context.Context, domain string, id int, editRequest *godo.DomainRecordEditRequest) (*godo.DomainRecord, *godo.Response, error) {
   168  	return &godo.DomainRecord{ID: 1}, nil, nil
   169  }
   170  
   171  func (m *mockDigitalOceanRecordsFail) Get(ctx context.Context, name string) (*godo.Domain, *godo.Response, error) {
   172  	return &godo.Domain{Name: "example.com"}, nil, nil
   173  }
   174  
   175  func (m *mockDigitalOceanRecordsFail) Record(ctx context.Context, domain string, id int) (*godo.DomainRecord, *godo.Response, error) {
   176  	return nil, nil, fmt.Errorf("Failed to get records")
   177  }
   178  
   179  func (m *mockDigitalOceanRecordsFail) Records(ctx context.Context, domain string, opt *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) {
   180  	return []godo.DomainRecord{}, nil, fmt.Errorf("Failed to get records")
   181  }
   182  
   183  func isEmpty(xs interface{}) bool {
   184  	if xs != nil {
   185  		objValue := reflect.ValueOf(xs)
   186  		return objValue.Len() == 0
   187  	}
   188  	return true
   189  }
   190  
   191  // This function is an adapted copy of the testify package's ElementsMatch function with the
   192  // call to ObjectsAreEqual replaced with cmp.Equal which better handles struct's with pointers to
   193  // other structs. It also ignores ordering when comparing unlike cmp.Equal.
   194  func elementsMatch(t *testing.T, listA, listB interface{}, msgAndArgs ...interface{}) (ok bool) {
   195  	if listA == nil && listB == nil {
   196  		return true
   197  	} else if listA == nil {
   198  		return isEmpty(listB)
   199  	} else if listB == nil {
   200  		return isEmpty(listA)
   201  	}
   202  
   203  	aKind := reflect.TypeOf(listA).Kind()
   204  	bKind := reflect.TypeOf(listB).Kind()
   205  
   206  	if aKind != reflect.Array && aKind != reflect.Slice {
   207  		return assert.Fail(t, fmt.Sprintf("%q has an unsupported type %s", listA, aKind), msgAndArgs...)
   208  	}
   209  
   210  	if bKind != reflect.Array && bKind != reflect.Slice {
   211  		return assert.Fail(t, fmt.Sprintf("%q has an unsupported type %s", listB, bKind), msgAndArgs...)
   212  	}
   213  
   214  	aValue := reflect.ValueOf(listA)
   215  	bValue := reflect.ValueOf(listB)
   216  
   217  	aLen := aValue.Len()
   218  	bLen := bValue.Len()
   219  
   220  	if aLen != bLen {
   221  		return assert.Fail(t, fmt.Sprintf("lengths don't match: %d != %d", aLen, bLen), msgAndArgs...)
   222  	}
   223  
   224  	// Mark indexes in bValue that we already used
   225  	visited := make([]bool, bLen)
   226  	for i := 0; i < aLen; i++ {
   227  		element := aValue.Index(i).Interface()
   228  		found := false
   229  		for j := 0; j < bLen; j++ {
   230  			if visited[j] {
   231  				continue
   232  			}
   233  			if cmp.Equal(bValue.Index(j).Interface(), element) {
   234  				visited[j] = true
   235  				found = true
   236  				break
   237  			}
   238  		}
   239  		if !found {
   240  			return assert.Fail(t, fmt.Sprintf("element %s appears more times in %s than in %s", element, aValue, bValue), msgAndArgs...)
   241  		}
   242  	}
   243  
   244  	return true
   245  }
   246  
   247  // Test adapted from test in testify library.
   248  // https://github.com/stretchr/testify/blob/b8f7d52a4a7c581d5ed42333572e7fb857c687c2/assert/assertions_test.go#L768-L796
   249  func TestElementsMatch(t *testing.T) {
   250  	mockT := new(testing.T)
   251  
   252  	cases := []struct {
   253  		expected interface{}
   254  		actual   interface{}
   255  		result   bool
   256  	}{
   257  		// matching
   258  		{nil, nil, true},
   259  
   260  		{nil, nil, true},
   261  		{[]int{}, []int{}, true},
   262  		{[]int{1}, []int{1}, true},
   263  		{[]int{1, 1}, []int{1, 1}, true},
   264  		{[]int{1, 2}, []int{1, 2}, true},
   265  		{[]int{1, 2}, []int{2, 1}, true},
   266  		{[2]int{1, 2}, [2]int{2, 1}, true},
   267  		{[]string{"hello", "world"}, []string{"world", "hello"}, true},
   268  		{[]string{"hello", "hello"}, []string{"hello", "hello"}, true},
   269  		{[]string{"hello", "hello", "world"}, []string{"hello", "world", "hello"}, true},
   270  		{[3]string{"hello", "hello", "world"}, [3]string{"hello", "world", "hello"}, true},
   271  		{[]int{}, nil, true},
   272  
   273  		// not matching
   274  		{[]int{1}, []int{1, 1}, false},
   275  		{[]int{1, 2}, []int{2, 2}, false},
   276  		{[]string{"hello", "hello"}, []string{"hello"}, false},
   277  	}
   278  
   279  	for _, c := range cases {
   280  		t.Run(fmt.Sprintf("ElementsMatch(%#v, %#v)", c.expected, c.actual), func(t *testing.T) {
   281  			res := elementsMatch(mockT, c.actual, c.expected)
   282  
   283  			if res != c.result {
   284  				t.Errorf("elementsMatch(%#v, %#v) should return %v", c.actual, c.expected, c.result)
   285  			}
   286  		})
   287  	}
   288  }
   289  
   290  func TestDigitalOceanZones(t *testing.T) {
   291  	provider := &DigitalOceanProvider{
   292  		Client:       &mockDigitalOceanClient{},
   293  		domainFilter: endpoint.NewDomainFilter([]string{"com"}),
   294  	}
   295  
   296  	zones, err := provider.Zones(context.Background())
   297  	if err != nil {
   298  		t.Fatal(err)
   299  	}
   300  
   301  	validateDigitalOceanZones(t, zones, []godo.Domain{
   302  		{Name: "foo.com"}, {Name: "example.com"}, {Name: "bar.com"},
   303  	})
   304  }
   305  
   306  func TestDigitalOceanMakeDomainEditRequest(t *testing.T) {
   307  	// Ensure that records at the root of the zone get `@` as the name.
   308  	r1 := makeDomainEditRequest("example.com", "example.com", endpoint.RecordTypeA,
   309  		"1.2.3.4", digitalOceanRecordTTL)
   310  	assert.Equal(t, &godo.DomainRecordEditRequest{
   311  		Type: endpoint.RecordTypeA,
   312  		Name: "@",
   313  		Data: "1.2.3.4",
   314  		TTL:  digitalOceanRecordTTL,
   315  	}, r1)
   316  
   317  	// Ensure the CNAME records have a `.` appended.
   318  	r2 := makeDomainEditRequest("example.com", "foo.example.com", endpoint.RecordTypeCNAME,
   319  		"bar.example.com", digitalOceanRecordTTL)
   320  	assert.Equal(t, &godo.DomainRecordEditRequest{
   321  		Type: endpoint.RecordTypeCNAME,
   322  		Name: "foo",
   323  		Data: "bar.example.com.",
   324  		TTL:  digitalOceanRecordTTL,
   325  	}, r2)
   326  
   327  	// Ensure that CNAME records do not have an extra `.` appended if they already have a `.`
   328  	r3 := makeDomainEditRequest("example.com", "foo.example.com", endpoint.RecordTypeCNAME,
   329  		"bar.example.com.", digitalOceanRecordTTL)
   330  	assert.Equal(t, &godo.DomainRecordEditRequest{
   331  		Type: endpoint.RecordTypeCNAME,
   332  		Name: "foo",
   333  		Data: "bar.example.com.",
   334  		TTL:  digitalOceanRecordTTL,
   335  	}, r3)
   336  
   337  	// Ensure that custom TTLs can be set
   338  	customTTL := 600
   339  	r4 := makeDomainEditRequest("example.com", "foo.example.com", endpoint.RecordTypeCNAME,
   340  		"bar.example.com.", customTTL)
   341  	assert.Equal(t, &godo.DomainRecordEditRequest{
   342  		Type: endpoint.RecordTypeCNAME,
   343  		Name: "foo",
   344  		Data: "bar.example.com.",
   345  		TTL:  customTTL,
   346  	}, r4)
   347  }
   348  
   349  func TestDigitalOceanApplyChanges(t *testing.T) {
   350  	changes := &plan.Changes{}
   351  	provider := &DigitalOceanProvider{
   352  		Client: &mockDigitalOceanClient{},
   353  	}
   354  	changes.Create = []*endpoint.Endpoint{
   355  		{DNSName: "new.ext-dns-test.bar.com", Targets: endpoint.Targets{"target"}},
   356  		{DNSName: "new.ext-dns-test-with-ttl.bar.com", Targets: endpoint.Targets{"target"}, RecordTTL: 100},
   357  		{DNSName: "new.ext-dns-test.unexpected.com", Targets: endpoint.Targets{"target"}},
   358  		{DNSName: "bar.com", Targets: endpoint.Targets{"target"}},
   359  	}
   360  	changes.Delete = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.bar.com", Targets: endpoint.Targets{"target"}}}
   361  	changes.UpdateOld = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.bar.de", Targets: endpoint.Targets{"target-old"}}}
   362  	changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.foo.com", Targets: endpoint.Targets{"target-new"}, RecordType: "CNAME", RecordTTL: 100}}
   363  	err := provider.ApplyChanges(context.Background(), changes)
   364  	if err != nil {
   365  		t.Errorf("should not fail, %s", err)
   366  	}
   367  }
   368  
   369  func TestDigitalOceanProcessCreateActions(t *testing.T) {
   370  	recordsByDomain := map[string][]godo.DomainRecord{
   371  		"example.com": nil,
   372  	}
   373  
   374  	createsByDomain := map[string][]*endpoint.Endpoint{
   375  		"example.com": {
   376  			endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.4"),
   377  			endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "foo.example.com"),
   378  		},
   379  	}
   380  
   381  	var changes digitalOceanChanges
   382  	err := processCreateActions(recordsByDomain, createsByDomain, &changes)
   383  	require.NoError(t, err)
   384  
   385  	assert.Equal(t, 2, len(changes.Creates))
   386  	assert.Equal(t, 0, len(changes.Updates))
   387  	assert.Equal(t, 0, len(changes.Deletes))
   388  
   389  	expectedCreates := []*digitalOceanChangeCreate{
   390  		{
   391  			Domain: "example.com",
   392  			Options: &godo.DomainRecordEditRequest{
   393  				Name: "foo",
   394  				Type: endpoint.RecordTypeA,
   395  				Data: "1.2.3.4",
   396  				TTL:  digitalOceanRecordTTL,
   397  			},
   398  		},
   399  		{
   400  			Domain: "example.com",
   401  			Options: &godo.DomainRecordEditRequest{
   402  				Name: "@",
   403  				Type: endpoint.RecordTypeCNAME,
   404  				Data: "foo.example.com.",
   405  				TTL:  digitalOceanRecordTTL,
   406  			},
   407  		},
   408  	}
   409  
   410  	if !elementsMatch(t, expectedCreates, changes.Creates) {
   411  		assert.Failf(t, "diff: %s", cmp.Diff(expectedCreates, changes.Creates))
   412  	}
   413  }
   414  
   415  func TestDigitalOceanProcessUpdateActions(t *testing.T) {
   416  	recordsByDomain := map[string][]godo.DomainRecord{
   417  		"example.com": {
   418  			{
   419  				ID:   1,
   420  				Name: "foo",
   421  				Type: endpoint.RecordTypeA,
   422  				Data: "1.2.3.4",
   423  				TTL:  digitalOceanRecordTTL,
   424  			},
   425  			{
   426  				ID:   2,
   427  				Name: "foo",
   428  				Type: endpoint.RecordTypeA,
   429  				Data: "5.6.7.8",
   430  				TTL:  digitalOceanRecordTTL,
   431  			},
   432  			{
   433  				ID:   3,
   434  				Name: "@",
   435  				Type: endpoint.RecordTypeCNAME,
   436  				Data: "foo.example.com.",
   437  				TTL:  digitalOceanRecordTTL,
   438  			},
   439  		},
   440  	}
   441  
   442  	updatesByDomain := map[string][]*endpoint.Endpoint{
   443  		"example.com": {
   444  			endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "10.11.12.13"),
   445  			endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "bar.example.com"),
   446  		},
   447  	}
   448  
   449  	var changes digitalOceanChanges
   450  	err := processUpdateActions(recordsByDomain, updatesByDomain, &changes)
   451  	require.NoError(t, err)
   452  
   453  	assert.Equal(t, 2, len(changes.Creates))
   454  	assert.Equal(t, 0, len(changes.Updates))
   455  	assert.Equal(t, 3, len(changes.Deletes))
   456  
   457  	expectedCreates := []*digitalOceanChangeCreate{
   458  		{
   459  			Domain: "example.com",
   460  			Options: &godo.DomainRecordEditRequest{
   461  				Name: "foo",
   462  				Type: endpoint.RecordTypeA,
   463  				Data: "10.11.12.13",
   464  				TTL:  digitalOceanRecordTTL,
   465  			},
   466  		},
   467  		{
   468  			Domain: "example.com",
   469  			Options: &godo.DomainRecordEditRequest{
   470  				Name: "@",
   471  				Type: endpoint.RecordTypeCNAME,
   472  				Data: "bar.example.com.",
   473  				TTL:  digitalOceanRecordTTL,
   474  			},
   475  		},
   476  	}
   477  
   478  	if !elementsMatch(t, expectedCreates, changes.Creates) {
   479  		assert.Failf(t, "diff: %s", cmp.Diff(expectedCreates, changes.Creates))
   480  	}
   481  
   482  	expectedDeletes := []*digitalOceanChangeDelete{
   483  		{
   484  			Domain:   "example.com",
   485  			RecordID: 1,
   486  		},
   487  		{
   488  			Domain:   "example.com",
   489  			RecordID: 2,
   490  		},
   491  		{
   492  			Domain:   "example.com",
   493  			RecordID: 3,
   494  		},
   495  	}
   496  
   497  	if !elementsMatch(t, expectedDeletes, changes.Deletes) {
   498  		assert.Failf(t, "diff: %s", cmp.Diff(expectedDeletes, changes.Deletes))
   499  	}
   500  }
   501  
   502  func TestDigitalOceanProcessDeleteActions(t *testing.T) {
   503  	recordsByDomain := map[string][]godo.DomainRecord{
   504  		"example.com": {
   505  			{
   506  				ID:   1,
   507  				Name: "foo",
   508  				Type: endpoint.RecordTypeA,
   509  				Data: "1.2.3.4",
   510  				TTL:  digitalOceanRecordTTL,
   511  			},
   512  			// This record will not be deleted because it represents a target not specified to be deleted.
   513  			{
   514  				ID:   2,
   515  				Name: "foo",
   516  				Type: endpoint.RecordTypeA,
   517  				Data: "5.6.7.8",
   518  				TTL:  digitalOceanRecordTTL,
   519  			},
   520  			{
   521  				ID:   3,
   522  				Name: "@",
   523  				Type: endpoint.RecordTypeCNAME,
   524  				Data: "foo.example.com.",
   525  				TTL:  digitalOceanRecordTTL,
   526  			},
   527  		},
   528  	}
   529  
   530  	deletesByDomain := map[string][]*endpoint.Endpoint{
   531  		"example.com": {
   532  			endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.4"),
   533  			endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "foo.example.com"),
   534  		},
   535  	}
   536  
   537  	var changes digitalOceanChanges
   538  	err := processDeleteActions(recordsByDomain, deletesByDomain, &changes)
   539  	require.NoError(t, err)
   540  
   541  	assert.Equal(t, 0, len(changes.Creates))
   542  	assert.Equal(t, 0, len(changes.Updates))
   543  	assert.Equal(t, 2, len(changes.Deletes))
   544  
   545  	expectedDeletes := []*digitalOceanChangeDelete{
   546  		{
   547  			Domain:   "example.com",
   548  			RecordID: 1,
   549  		},
   550  		{
   551  			Domain:   "example.com",
   552  			RecordID: 3,
   553  		},
   554  	}
   555  
   556  	if !elementsMatch(t, expectedDeletes, changes.Deletes) {
   557  		assert.Failf(t, "diff: %s", cmp.Diff(expectedDeletes, changes.Deletes))
   558  	}
   559  }
   560  
   561  func TestNewDigitalOceanProvider(t *testing.T) {
   562  	_ = os.Setenv("DO_TOKEN", "xxxxxxxxxxxxxxxxx")
   563  	_, err := NewDigitalOceanProvider(context.Background(), endpoint.NewDomainFilter([]string{"ext-dns-test.zalando.to."}), true, 50)
   564  	if err != nil {
   565  		t.Errorf("should not fail, %s", err)
   566  	}
   567  	_ = os.Unsetenv("DO_TOKEN")
   568  	_, err = NewDigitalOceanProvider(context.Background(), endpoint.NewDomainFilter([]string{"ext-dns-test.zalando.to."}), true, 50)
   569  	if err == nil {
   570  		t.Errorf("expected to fail")
   571  	}
   572  }
   573  
   574  func TestDigitalOceanGetMatchingDomainRecords(t *testing.T) {
   575  	records := []godo.DomainRecord{
   576  		{
   577  			ID:   1,
   578  			Name: "foo",
   579  			Type: endpoint.RecordTypeCNAME,
   580  			Data: "baz.org.",
   581  		},
   582  		{
   583  			ID:   2,
   584  			Name: "baz",
   585  			Type: endpoint.RecordTypeA,
   586  			Data: "1.2.3.4",
   587  		},
   588  		{
   589  			ID:   3,
   590  			Name: "baz",
   591  			Type: endpoint.RecordTypeA,
   592  			Data: "5.6.7.8",
   593  		},
   594  		{
   595  			ID:   4,
   596  			Name: "@",
   597  			Type: endpoint.RecordTypeA,
   598  			Data: "9.10.11.12",
   599  		},
   600  	}
   601  
   602  	ep1 := endpoint.NewEndpoint("foo.com", endpoint.RecordTypeCNAME)
   603  	assert.Equal(t, 1, len(getMatchingDomainRecords(records, "com", ep1)))
   604  
   605  	ep2 := endpoint.NewEndpoint("foo.com", endpoint.RecordTypeA)
   606  	assert.Equal(t, 0, len(getMatchingDomainRecords(records, "com", ep2)))
   607  
   608  	ep3 := endpoint.NewEndpoint("baz.org", endpoint.RecordTypeA)
   609  	r := getMatchingDomainRecords(records, "org", ep3)
   610  	assert.Equal(t, 2, len(r))
   611  	assert.ElementsMatch(t, r, []godo.DomainRecord{
   612  		{
   613  			ID:   2,
   614  			Name: "baz",
   615  			Type: endpoint.RecordTypeA,
   616  			Data: "1.2.3.4",
   617  		},
   618  		{
   619  			ID:   3,
   620  			Name: "baz",
   621  			Type: endpoint.RecordTypeA,
   622  			Data: "5.6.7.8",
   623  		},
   624  	})
   625  
   626  	ep4 := endpoint.NewEndpoint("example.com", endpoint.RecordTypeA)
   627  	r2 := getMatchingDomainRecords(records, "example.com", ep4)
   628  	assert.Equal(t, 1, len(r2))
   629  	assert.Equal(t, "9.10.11.12", r2[0].Data)
   630  }
   631  
   632  func validateDigitalOceanZones(t *testing.T, zones []godo.Domain, expected []godo.Domain) {
   633  	require.Len(t, zones, len(expected))
   634  
   635  	for i, zone := range zones {
   636  		assert.Equal(t, expected[i].Name, zone.Name)
   637  	}
   638  }
   639  
   640  func TestDigitalOceanRecord(t *testing.T) {
   641  	provider := &DigitalOceanProvider{
   642  		Client: &mockDigitalOceanClient{},
   643  	}
   644  
   645  	records, err := provider.fetchRecords(context.Background(), "example.com")
   646  	if err != nil {
   647  		t.Fatal(err)
   648  	}
   649  	expected := []godo.DomainRecord{{ID: 1, Name: "new", Type: "CNAME"}}
   650  	require.Len(t, records, len(expected))
   651  	for i, record := range records {
   652  		assert.Equal(t, expected[i].Name, record.Name)
   653  	}
   654  }
   655  
   656  func TestDigitalOceanAllRecords(t *testing.T) {
   657  	provider := &DigitalOceanProvider{
   658  		Client: &mockDigitalOceanClient{},
   659  	}
   660  	ctx := context.Background()
   661  
   662  	records, err := provider.Records(ctx)
   663  	if err != nil {
   664  		t.Errorf("should not fail, %s", err)
   665  	}
   666  	require.Equal(t, 5, len(records))
   667  
   668  	provider.Client = &mockDigitalOceanRecordsFail{}
   669  	_, err = provider.Records(ctx)
   670  	if err == nil {
   671  		t.Errorf("expected to fail, %s", err)
   672  	}
   673  }
   674  
   675  func TestDigitalOceanMergeRecordsByNameType(t *testing.T) {
   676  	xs := []*endpoint.Endpoint{
   677  		endpoint.NewEndpoint("foo.example.com", "A", "1.2.3.4"),
   678  		endpoint.NewEndpoint("bar.example.com", "A", "1.2.3.4"),
   679  		endpoint.NewEndpoint("foo.example.com", "A", "5.6.7.8"),
   680  		endpoint.NewEndpoint("foo.example.com", "CNAME", "somewhere.out.there.com"),
   681  	}
   682  
   683  	merged := mergeEndpointsByNameType(xs)
   684  
   685  	assert.Equal(t, 3, len(merged))
   686  	sort.SliceStable(merged, func(i, j int) bool {
   687  		if merged[i].DNSName != merged[j].DNSName {
   688  			return merged[i].DNSName < merged[j].DNSName
   689  		}
   690  		return merged[i].RecordType < merged[j].RecordType
   691  	})
   692  	assert.Equal(t, "bar.example.com", merged[0].DNSName)
   693  	assert.Equal(t, "A", merged[0].RecordType)
   694  	assert.Equal(t, 1, len(merged[0].Targets))
   695  	assert.Equal(t, "1.2.3.4", merged[0].Targets[0])
   696  
   697  	assert.Equal(t, "foo.example.com", merged[1].DNSName)
   698  	assert.Equal(t, "A", merged[1].RecordType)
   699  	assert.Equal(t, 2, len(merged[1].Targets))
   700  	assert.ElementsMatch(t, []string{"1.2.3.4", "5.6.7.8"}, merged[1].Targets)
   701  
   702  	assert.Equal(t, "foo.example.com", merged[2].DNSName)
   703  	assert.Equal(t, "CNAME", merged[2].RecordType)
   704  	assert.Equal(t, 1, len(merged[2].Targets))
   705  	assert.Equal(t, "somewhere.out.there.com", merged[2].Targets[0])
   706  }