diff --git a/.gitignore b/.gitignore index e9c45e9..d56674c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .envrc _gitignore/ *~ +coverage.out diff --git a/README.md b/README.md index dd00348..571b155 100644 --- a/README.md +++ b/README.md @@ -60,21 +60,33 @@ tests: $ export LIBDNS_IONOS_TEST_ZONE=mydomain.org $ export LIBDNS_IONOS_TEST_TOKEN=aaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb $ go test -v +go test -v === RUN Test_AppendRecords ---- PASS: Test_AppendRecords (43.01s) +=== RUN Test_AppendRecords/testcase_0 +=== RUN Test_AppendRecords/testcase_1 +=== RUN Test_AppendRecords/testcase_2 +--- PASS: Test_AppendRecords (6.71s) + --- PASS: Test_AppendRecords/testcase_0 (2.51s) + --- PASS: Test_AppendRecords/testcase_1 (2.15s) + --- PASS: Test_AppendRecords/testcase_2 (2.05s) === RUN Test_DeleteRecords ---- PASS: Test_DeleteRecords (23.91s) +=== RUN Test_DeleteRecords/clear_record.ID=true +=== RUN Test_DeleteRecords/clear_record.ID=false +--- PASS: Test_DeleteRecords (9.62s) + --- PASS: Test_DeleteRecords/clear_record.ID=true (4.81s) + --- PASS: Test_DeleteRecords/clear_record.ID=false (4.80s) === RUN Test_GetRecords ---- PASS: Test_GetRecords (30.96s) -=== RUN Test_SetRecords ---- PASS: Test_SetRecords (51.39s) +--- PASS: Test_GetRecords (4.41s) +=== RUN Test_UpdateRecords +=== RUN Test_UpdateRecords/clear_record.ID=true +=== RUN Test_UpdateRecords/clear_record.ID=false +--- PASS: Test_UpdateRecords (10.14s) + --- PASS: Test_UpdateRecords/clear_record.ID=true (5.84s) + --- PASS: Test_UpdateRecords/clear_record.ID=false (4.30s) PASS -ok github.com/libdns/ionos 149.277s +ok github.com/libdns/ionos 30.884s ``` -The tests were taken from the [Hetzner libdns -module](https://github.com/libdns/hetzner) and are not modified. - ## Author Original Work (C) Copyright 2020 by matthiasng (based on https://github.com/libdns/hetzner), diff --git a/_example/example.go b/_example/example.go new file mode 100644 index 0000000..6a945a7 --- /dev/null +++ b/_example/example.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/libdns/ionos" +) + +func main() { + token := os.Getenv("LIBDNS_IONOS_TOKEN") + if token == "" { + panic("LIBDNS_IONOS_TOKEN not set") + } + + zone := os.Getenv("LIBDNS_IONOS_ZONE") + if zone == "" { + panic("LIBDNS_IONOS_ZONE not set") + } + + p := &ionos.Provider{ + AuthAPIToken: token, + } + + records, err := p.GetRecords(context.TODO(), zone) + if err != nil { + panic(err) + } + + out, _ := json.MarshalIndent(records, "", " ") + fmt.Println(string(out)) +} diff --git a/client.go b/client.go index 34105b2..af1e3d3 100644 --- a/client.go +++ b/client.go @@ -6,11 +6,9 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" - "time" - - "github.com/libdns/libdns" + "net/url" ) const ( @@ -57,7 +55,7 @@ type record struct { // IONOS does not accept TTL values < 60, and returns status 400. If the // TTL is 0, we leave the field empty, by setting the struct value to nil. -func optTTL(ttl float64) *int { +func ionosTTL(ttl float64) *int { var intTTL *int if ttl > 0 { tmp := int(ttl) @@ -71,7 +69,10 @@ func doRequest(token string, request *http.Request) ([]byte, error) { request.Header.Add("Content-Type", "application/json") client := &http.Client{} // no timeout set because request is w/ context + // fmt.Printf(">>> HTTP req: %+v\n\n", request) response, err := client.Do(request) + + // fmt.Printf("<<< HTTP res: %+v\n\n", response) if err != nil { return nil, err } @@ -80,11 +81,12 @@ func doRequest(token string, request *http.Request) ([]byte, error) { if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("%s (%d)", http.StatusText(response.StatusCode), response.StatusCode) } - return ioutil.ReadAll(response.Body) + return io.ReadAll(response.Body) + } // GET /v1/zones -func getAllZones(ctx context.Context, token string) (getAllZonesResponse, error) { +func ionosGetAllZones(ctx context.Context, token string) (getAllZonesResponse, error) { uri := fmt.Sprintf("%s/zones", APIEndpoint) req, err := http.NewRequestWithContext(ctx, "GET", uri, nil) data, err := doRequest(token, req) @@ -99,25 +101,25 @@ func getAllZones(ctx context.Context, token string) (getAllZonesResponse, error) return getAllZonesResponse{zones}, err } -// findZoneDescriptor finds the zoneDescriptor for the named zoned in all zones -func findZoneDescriptor(ctx context.Context, token string, zoneName string) (zoneDescriptor, error) { - allZones, err := getAllZones(ctx, token) +// ionosGetZone reads the contents of zone by it's IONOS zoneID, optionally filtering for +// a specific recordType and recordName. +// /v1/zones/{zoneId} +func ionosGetZone(ctx context.Context, token string, zoneID string, recordType, recordName string) (getZoneResponse, error) { + u, err := url.Parse(APIEndpoint) if err != nil { - return zoneDescriptor{}, err + return getZoneResponse{}, err } - for _, zone := range allZones.Zones { - if zone.Name == zoneName { - return zone, nil - } + u = u.JoinPath("zones", zoneID) + queryString := u.Query() + if recordType != "" { + queryString.Set("recordType", recordType) } - return zoneDescriptor{}, fmt.Errorf("zone not found") -} + if recordName != "" { + queryString.Set("recordName", recordName) + } + u.RawQuery = queryString.Encode() -// getZone reads a zone by it's IONOS zoneID -// /v1/zones/{zoneId} -func getZone(ctx context.Context, token string, zoneID string) (getZoneResponse, error) { - uri := fmt.Sprintf("%s/zones/%s", APIEndpoint, zoneID) - req, err := http.NewRequestWithContext(ctx, "GET", uri, nil) + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) data, err := doRequest(token, req) var result getZoneResponse if err != nil { @@ -128,159 +130,93 @@ func getZone(ctx context.Context, token string, zoneID string) (getZoneResponse, return result, err } -// findRecordInZone searches all records in the given zone for a record with -// the given name and type and returns this record on success -func findRecordInZone(ctx context.Context, token, zoneName, name, typ string) (zoneRecord, error) { - zoneResp, err := getZoneByName(ctx, token, zoneName) +// ionosFindRecordsInZone is a convenience function to search all records in the +// given zone for a record with the given name and type and returns this record +// on success +func ionosFindRecordsInZone(ctx context.Context, token string, zoneID, typ, name string) ([]zoneRecord, error) { + resp, err := ionosGetZone(ctx, token, zoneID, typ, name) if err != nil { - return zoneRecord{}, err + return nil, err } - - for _, r := range zoneResp.Records { - if r.Name == name && r.Type == typ { - return r, nil - } + if len(resp.Records) < 1 { + return nil, fmt.Errorf("record not found in zone") } - return zoneRecord{}, fmt.Errorf("record not found") + return resp.Records, nil } -// getZoneByName reads a zone by it's zone name, requiring 2 REST calls to -// the IONOS API -func getZoneByName(ctx context.Context, token, zoneName string) (getZoneResponse, error) { - zoneDes, err := findZoneDescriptor(ctx, token, zoneName) - if err != nil { - return getZoneResponse{}, err +// ionosDeleteRecord deletes the given record +// DELETE /v1/zones/{zoneId}/records/{recordId} +func ionosDeleteRecord(ctx context.Context, token string, zoneID, id string) error { + + if id == "" { + return fmt.Errorf("no record id provided") } - return getZone(ctx, token, zoneDes.ID) -} -// getAllRecords returns all records from the given zone -func getAllRecords(ctx context.Context, token string, zoneName string) ([]libdns.Record, error) { - zoneResp, err := getZoneByName(ctx, token, zoneName) + req, err := http.NewRequestWithContext(ctx, "DELETE", + fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneID, id), nil) + if err != nil { - return nil, err - } - records := []libdns.Record{} - for _, r := range zoneResp.Records { - records = append(records, libdns.Record{ - ID: r.ID, - Type: r.Type, - // libdns Name is partially qualified, relative to zone - Name: libdns.RelativeName(r.Name, zoneResp.Name), - Value: r.Content, - TTL: time.Duration(r.TTL) * time.Second, - }) + return err } - return records, nil + _, err = doRequest(token, req) + return err } -// createRecord creates a DNS record in the given zone +// ionosCreateRecord creates a batch of DNS record in the given zone // POST /v1/zones/{zoneId}/records -func createRecord(ctx context.Context, token string, zoneName string, r libdns.Record) (libdns.Record, error) { - zoneResp, err := getZoneByName(ctx, token, zoneName) - if err != nil { - return libdns.Record{}, err - } - - reqData := []record{ - {Type: r.Type, - // IONOS: Name is fully qualified - Name: libdns.AbsoluteName(r.Name, zoneName), - Content: r.Value, - TTL: optTTL(r.TTL.Seconds()), - }} +func ionosCreateRecords( + ctx context.Context, + token string, + zoneID string, + records []record) ([]zoneRecord, error) { - reqBuffer, err := json.Marshal(reqData) + reqBuffer, err := json.Marshal(records) if err != nil { - return libdns.Record{}, err + return nil, err } - uri := fmt.Sprintf("%s/zones/%s/records", APIEndpoint, zoneResp.ID) + uri := fmt.Sprintf("%s/zones/%s/records", APIEndpoint, zoneID) req, err := http.NewRequestWithContext(ctx, "POST", uri, bytes.NewBuffer(reqBuffer)) if err != nil { - return libdns.Record{}, err + return nil, err } - // as result of the POST, a zoneDescriptor array is returned - data, err := doRequest(token, req) + // as result of the POST, a zoneRecord array is returned + res, err := doRequest(token, req) if err != nil { - return libdns.Record{}, err - } - zones := make([]zoneDescriptor, 0) - if err = json.Unmarshal(data, &zones); err != nil { - return libdns.Record{}, err + return nil, err } - if len(zones) != 1 { - return libdns.Record{}, fmt.Errorf("unexpected response from create record (size mismatch)") + var zoneRecords []zoneRecord + if err = json.Unmarshal(res, &zoneRecords); err != nil { + return nil, err } + return zoneRecords, nil - return libdns.Record{ - ID: zones[0].ID, - Type: r.Type, - // always return partially qualified name, relative to zone for libdns - Name: libdns.RelativeName(unFQDN(r.Name), zoneName), - Value: r.Value, - TTL: r.TTL, - }, nil } -// DELETE /v1/zones/{zoneId}/records/{recordId} -func deleteRecord(ctx context.Context, token, zoneName string, record libdns.Record) error { - zoneResp, err := getZoneByName(ctx, token, zoneName) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, "DELETE", - fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneResp.ID, record.ID), nil) - if err != nil { - return err - } - _, err = doRequest(token, req) - return err -} - -// /v1/zones/{zoneId}/records/{recordId} -func updateRecord(ctx context.Context, token string, zone string, r libdns.Record) (libdns.Record, error) { - zoneDes, err := getZoneByName(ctx, token, zone) - if err != nil { - return libdns.Record{}, err - } - - reqData := record{ - Type: r.Type, - Name: libdns.AbsoluteName(r.Name, zone), - Content: r.Value, - TTL: optTTL(r.TTL.Seconds()), +// ionosUpdateRecord updates the record with id `id` in the given zone +// TODO check TTL +// PUT /v1/zones/{zoneId}/records/{recordId} +func ionosUpdateRecord(ctx context.Context, token string, zoneID, id string, r record) error { + if id == "" { + return fmt.Errorf("no record id provided") } - reqBuffer, err := json.Marshal(reqData) + reqBuffer, err := json.Marshal(r) if err != nil { - return libdns.Record{}, err + return fmt.Errorf("marshal record for update: %w", err) } req, err := http.NewRequestWithContext(ctx, "PUT", - fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneDes.ID, r.ID), + fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneID, id), bytes.NewBuffer(reqBuffer)) if err != nil { - return libdns.Record{}, err + return err } + // according to API doc, no response returned here _, err = doRequest(token, req) - - return libdns.Record{ - ID: r.ID, - Type: r.Type, - Name: r.Name, - Value: r.Value, - TTL: time.Duration(r.TTL) * time.Second, - }, err -} - -func createOrUpdateRecord(ctx context.Context, token string, zone string, r libdns.Record) (libdns.Record, error) { - if r.ID == "" { - return createRecord(ctx, token, zone, r) - } - return updateRecord(ctx, token, zone, r) + return err } diff --git a/go.mod b/go.mod index 5654c51..e096c66 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/libdns/ionos -go 1.16 +go 1.18 -require github.com/libdns/libdns v0.2.1 +require github.com/libdns/libdns v0.2.2 diff --git a/go.sum b/go.sum index ba9d0cf..9a6001b 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis= -github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= +github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= diff --git a/provider.go b/provider.go index 144b97c..05261be 100644 --- a/provider.go +++ b/provider.go @@ -1,17 +1,14 @@ // libdns implementation for IONOS DNS API. -// libdns uses FQDN, i.e. domain names that terminate with ".". From the -// libdns documentation: -// For example, an A record called "sub" in zone "example.com." represents -// a fully-qualified domain name (FQDN) of "sub.example.com." -// -// The IONOS API seems not to use FQDNs. -// -// https://developer.hosting.ionos.de/docs/dns +// IONOS API documentaion: https://developer.hosting.ionos.de/docs/dns +// libdns: https://github.com/libdns/libdns package ionos import ( "context" + "fmt" + "strconv" "strings" + "time" "github.com/libdns/libdns" ) @@ -23,50 +20,205 @@ type Provider struct { AuthAPIToken string `json:"auth_api_token"` } +func toIonosRecord(r libdns.Record, zoneName string) record { + return record{ + Type: r.Type, + Name: libdns.AbsoluteName(r.Name, zoneName), + Content: r.Value, + TTL: ionosTTL(r.TTL.Seconds()), + } +} + +func fromIonosRecord(r zoneRecord, zoneName string) libdns.Record { + // IONOS returns TXT records quoted: remove quotes + var value string + if strings.ToUpper(r.Type) == "TXT" { + value, _ = strconv.Unquote(r.Content) + } else { + value = r.Content + } + return libdns.Record{ + ID: r.ID, + Type: r.Type, + // libdns Name is partially qualified, relative to zone, Ionos absoulte + Name: libdns.RelativeName(r.Name, zoneName), // use r.rootName for zoneName TODO? + Value: value, + TTL: time.Duration(r.TTL) * time.Second, + } +} + +func (p *Provider) findZoneByName(ctx context.Context, zoneName string) (zoneDescriptor, error) { + // obtain list of all zones + zones, err := ionosGetAllZones(ctx, p.AuthAPIToken) + if err != nil { + return zoneDescriptor{}, fmt.Errorf("get all zones: %w", err) + } + + // find the desired zone + for _, zone := range zones.Zones { + if zone.Name == unFQDN(zoneName) { + return zone, nil + } + } + return zoneDescriptor{}, fmt.Errorf("zone named not found (%s)", zoneName) +} + // GetRecords lists all the records in the zone. -func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { - return getAllRecords(ctx, p.AuthAPIToken, unFQDN(zone)) +func (p *Provider) GetRecords(ctx context.Context, zoneName string) ([]libdns.Record, error) { + + zoneDes, err := p.findZoneByName(ctx, zoneName) + if err != nil { + return nil, fmt.Errorf("find zone: %w", err) + } + + // obtain list of all records in zone + zoneResp, err := ionosGetZone(ctx, p.AuthAPIToken, zoneDes.ID, "", "") + if err != nil { + return nil, fmt.Errorf("get zone records: %w", err) + } + + records := make([]libdns.Record, len(zoneResp.Records)) + for i, r := range zoneResp.Records { + records[i] = fromIonosRecord(r, zoneName) + } + return records, nil } // AppendRecords adds records to the zone. It returns the records that were added. -func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { - var appendedRecords []libdns.Record +func (p *Provider) AppendRecords( + ctx context.Context, + zone string, + records []libdns.Record) ([]libdns.Record, error) { + + zoneDes, err := p.findZoneByName(ctx, zone) + if err != nil { + return nil, fmt.Errorf("find zone: %w", err) + } + + // populate ionos request + reqs := make([]record, len(records)) + for i, r := range records { + reqs[i] = toIonosRecord(r, zoneDes.Name) + } + + newRecords, err := ionosCreateRecords(ctx, p.AuthAPIToken, zoneDes.ID, reqs) + if err != nil { + return nil, fmt.Errorf("create records: %w", err) + } + + // populate libdns response + res := make([]libdns.Record, len(records)) + for i, r := range newRecords { + res[i] = fromIonosRecord(r, zoneDes.Name) + } + return res, nil +} + +// DeleteRecords deletes the records from the zone. Returns the list of +// records acutally deleted. Fails fast on first error, but in this case +func (p *Provider) DeleteRecords( + ctx context.Context, + zone string, + records []libdns.Record) ([]libdns.Record, error) { + + zoneDes, err := p.findZoneByName(ctx, zone) + if err != nil { + return nil, fmt.Errorf("find zone: %w", err) + } - for _, record := range records { - newRecord, err := createRecord(ctx, p.AuthAPIToken, unFQDN(zone), record) + // ionos api has no batch-delete, delete one record at a time + var deleteQueue []libdns.Record // list of record IDs to delete + + // collect IDs + for _, r := range records { + // no ID provided, search record first + if r.ID == "" { + // safety: avoid to delete the whole zone + if r.Type == "" || r.Name == "" { + continue + } + + name := libdns.AbsoluteName(r.Name, zoneDes.Name) + existing, err := ionosFindRecordsInZone(ctx, p.AuthAPIToken, zoneDes.ID, r.Type, name) + if err != nil { + return nil, fmt.Errorf("find record for deletion: %w", err) + } + for _, found := range existing { + deleteQueue = append(deleteQueue, fromIonosRecord(found, zoneDes.Name)) + } + } else { + deleteQueue = append(deleteQueue, r) + } + } + // delete all collected records + for _, r := range deleteQueue { + err := ionosDeleteRecord(ctx, p.AuthAPIToken, zoneDes.ID, r.ID) if err != nil { - return nil, err + return nil, fmt.Errorf("delete record by ID: %w", err) } - appendedRecords = append(appendedRecords, newRecord) } - return appendedRecords, nil + + return deleteQueue, nil } -// DeleteRecords deletes the records from the zone. -func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { - for _, record := range records { - err := deleteRecord(ctx, p.AuthAPIToken, unFQDN(zone), record) +func (p *Provider) createOrUpdateRecord( + ctx context.Context, + zoneDes zoneDescriptor, + r libdns.Record) (libdns.Record, error) { + + // an ID is provided, we can directly call the ionos api + if r.ID != "" { + err := ionosUpdateRecord(ctx, p.AuthAPIToken, zoneDes.ID, r.ID, toIonosRecord(r, zoneDes.Name)) if err != nil { - return nil, err + return r, fmt.Errorf("update record: %w", err) } + return r, nil } - return records, nil + + // before we create a new record, make sure there is no existing record + // of same (type, name). In this case we only update the record + name := libdns.AbsoluteName(r.Name, zoneDes.Name) + existing, err := ionosFindRecordsInZone(ctx, p.AuthAPIToken, zoneDes.ID, r.Type, name) + if err == nil { + if len(existing) != 1 { + return r, fmt.Errorf("found unexpected number of records during delete, expected 1 (%d)", len(existing)) + } + err := ionosUpdateRecord(ctx, p.AuthAPIToken, zoneDes.ID, existing[0].ID, toIonosRecord(r, zoneDes.Name)) + if err != nil { + return r, fmt.Errorf("update found record: %w", err) + } + r.ID = existing[0].ID + return r, nil + } + + created, err := ionosCreateRecords(ctx, p.AuthAPIToken, zoneDes.ID, []record{toIonosRecord(r, zoneDes.Name)}) + if err != nil { + return r, fmt.Errorf("create new record: %w", err) + } + if len(created) != 1 { + return r, fmt.Errorf("expected one record to be created, got %d", len(created)) + } + return fromIonosRecord(created[0], zoneDes.Name), nil } // SetRecords sets the records in the zone, either by updating existing records // or creating new ones. It returns the updated records. func (p *Provider) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { - var setRecords []libdns.Record + var res []libdns.Record - for _, record := range records { - setRecord, err := createOrUpdateRecord(ctx, p.AuthAPIToken, unFQDN(zone), record) + zoneDes, err := p.findZoneByName(ctx, zone) + if err != nil { + return nil, fmt.Errorf("find zone: %w", err) + } + + for _, r := range records { + newRecord, err := p.createOrUpdateRecord(ctx, zoneDes, r) if err != nil { - return setRecords, err + return res, err } - setRecords = append(setRecords, setRecord) + res = append(res, newRecord) } - - return setRecords, nil + return res, nil } // unFQDN trims any trailing "." from fqdn. IONOS's API does not use FQDNs. diff --git a/provider_test.go b/provider_test.go index 5629e9a..88f6068 100644 --- a/provider_test.go +++ b/provider_test.go @@ -1,16 +1,18 @@ -// end-to-end test suite, using the original IONOS API service (i.e. no test -// doubles - be careful). -// set environment variables -// LIBDNS_IONOS_TEST_TOKEN - API token -// LIBDNS_IONOS_TEST_ZONE - domain +// end-to-end test suite, using the original IONOS API service (i.e. no test +// doubles - be careful). set environment variables +// +// LIBDNS_IONOS_TEST_TOKEN - API token +// LIBDNS_IONOS_TEST_ZONE - domain +// // before running the test. -// (Tests taken from github.com/libdns/hetzner) package ionos_test import ( "context" "fmt" + "math/rand" "os" + "slices" "strings" "testing" "time" @@ -25,71 +27,77 @@ var ( ttl = time.Duration(120 * time.Second) ) -type testRecordsCleanup = func() +var letters = []rune("abcdefghijklmnopqrstuvwxyz") -func setupTestRecords(t *testing.T, p *ionos.Provider) ([]libdns.Record, testRecordsCleanup) { - testRecords := []libdns.Record{ - { - Type: "TXT", - Name: "test1", - Value: "test1", - TTL: ttl, - }, { - Type: "TXT", - Name: "test2", - Value: "test2", - TTL: ttl, - }, { - Type: "TXT", - Name: "test3", - Value: "test3", - TTL: ttl, - }, { - Type: "TXT", - Name: "test4", - Value: "test4", - TTL: 0, - }, - } - - records, err := p.AppendRecords(context.TODO(), envZone, testRecords) - if err != nil { - t.Fatal(err) - return nil, func() {} - } - - return records, func() { - cleanupRecords(t, p, records) +func randSeq(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] } + return string(b) } func cleanupRecords(t *testing.T, p *ionos.Provider, r []libdns.Record) { + t.Helper() _, err := p.DeleteRecords(context.TODO(), envZone, r) if err != nil { t.Fatalf("cleanup failed: %v", err) } } -func TestMain(m *testing.M) { - envToken = os.Getenv("LIBDNS_IONOS_TEST_TOKEN") - envZone = os.Getenv("LIBDNS_IONOS_TEST_ZONE") +func checkExcatlyOneRecordExists( + t *testing.T, + records []libdns.Record, + recordType, name, value string) { + + t.Helper() + name = strings.ToLower(name) + found := 0 + for _, r := range records { + if r.Name == name { + found++ + if r.Type != recordType || r.Value != value { + t.Fatalf("expected to find excatly one %s record with name %s and value of %s", recordType, name, value) + } + } + } + if found != 1 { + t.Fatalf("expected to find only one record named %s, but found %d", value, found) + } +} - if len(envToken) == 0 || len(envZone) == 0 { - fmt.Println(`Please notice that this test runs agains the public ionos DNS Api, so you sould -never run the test with a zone, used in production. -To run this test, you have to specify 'LIBDNS_IONOS_TEST_TOKEN' and 'LIBDNS_IONOS_TEST_ZONE'. -Example: "LIBDNS_IONOS_TEST_TOKEN="123" LIBDNS_IONOS_TEST_ZONE="my-domain.com" go test ./... -v`) - os.Exit(1) +func checkNoRecordExists( + t *testing.T, + records []libdns.Record, + name string) { + + t.Helper() + for _, r := range records { + if r.Name == strings.ToLower(name) { + t.Fatalf("expected to find no record named %s", r.Name) + } } +} - os.Exit(m.Run()) +func containsRecord(probe libdns.Record, records []libdns.Record) *libdns.Record { + for _, r := range records { + if r.Name == probe.Name && + r.Type == probe.Type && + r.Value == probe.Value && + r.TTL == probe.TTL { + return &r + } + } + return nil } +// Test_AppendRecords creates various records using AppendRecords and checks +// that the response returned is as expected. Records are not read back +// using GetRecords, that's done in Test_GetRecords. func Test_AppendRecords(t *testing.T) { - p := &ionos.Provider{ - AuthAPIToken: envToken, - } + p := &ionos.Provider{AuthAPIToken: envToken} + prefix := randSeq(32) testCases := []struct { records []libdns.Record expected []libdns.Record @@ -97,183 +105,216 @@ func Test_AppendRecords(t *testing.T) { { // multiple records records: []libdns.Record{ - {Type: "TXT", Name: "test_1", Value: "test_1", TTL: ttl}, - {Type: "TXT", Name: "test_2", Value: "test_2", TTL: ttl}, - {Type: "TXT", Name: "test_3", Value: "test_3", TTL: ttl}, - {Type: "TXT", Name: "test_4", Value: "test_3", TTL: 0}, + {Type: "TXT", Name: prefix + "_atest_1", Value: "val_1", TTL: ttl}, + {Type: "TXT", Name: prefix + "_atest_2", Value: "val_2", TTL: 0}, }, expected: []libdns.Record{ - {Type: "TXT", Name: "test_1", Value: "test_1", TTL: ttl}, - {Type: "TXT", Name: "test_2", Value: "test_2", TTL: ttl}, - {Type: "TXT", Name: "test_3", Value: "test_3", TTL: ttl}, - {Type: "TXT", Name: "test_4", Value: "test_3", TTL: 0}, + {Type: "TXT", Name: prefix + "_atest_1", Value: "val_1", TTL: ttl}, + {Type: "TXT", Name: prefix + "_atest_2", Value: "val_2", TTL: time.Hour}, }, }, { // relative name records: []libdns.Record{ - {Type: "TXT", Name: "123.test", Value: "123", TTL: ttl}, - }, - expected: []libdns.Record{ - {Type: "TXT", Name: "123.test", Value: "123", TTL: ttl}, - }, - }, - { - // (fqdn) sans trailing dot - records: []libdns.Record{ - {Type: "TXT", Name: fmt.Sprintf("123.test.%s", strings.TrimSuffix(envZone, ".")), Value: "test", TTL: ttl}, + {Type: "TXT", Name: prefix + "123.atest", Value: "123", TTL: ttl}, }, expected: []libdns.Record{ - {Type: "TXT", Name: "123.test", Value: "test", TTL: ttl}, + {Type: "TXT", Name: prefix + "123.atest", Value: "123", TTL: ttl}, }, }, { - // fqdn with trailing dot + // A records records: []libdns.Record{ - {Type: "TXT", Name: fmt.Sprintf("123.test.%s.", strings.TrimSuffix(envZone, ".")), Value: "test", TTL: ttl}, + {Type: "A", Name: prefix + "456.atest.", Value: "1.2.3.4", TTL: ttl}, }, expected: []libdns.Record{ - {Type: "TXT", Name: "123.test", Value: "test", TTL: ttl}, + {Type: "A", Name: prefix + "456.atest", Value: "1.2.3.4", TTL: ttl}, }, }, } - for _, c := range testCases { - func() { - result, err := p.AppendRecords(context.TODO(), envZone+".", c.records) - if err != nil { - t.Fatal(err) - } - defer cleanupRecords(t, p, result) + for i, c := range testCases { + t.Run(fmt.Sprintf("testcase %d", i), + func(t *testing.T) { + result, err := p.AppendRecords(context.TODO(), envZone, c.records) + if err != nil { + t.Fatal(err) + } + defer cleanupRecords(t, p, result) - if len(result) != len(c.records) { - t.Fatalf("len(resilt) != len(c.records) => %d != %d", len(c.records), len(result)) - } + if len(result) != len(c.expected) { + t.Fatalf("unexpected number of records created: expected %d != actual %d", len(c.expected), len(result)) + } - for k, r := range result { - if len(result[k].ID) == 0 { - t.Fatalf("len(result[%d].ID) == 0", k) + // results are returned in arbitrary order + for _, r := range c.expected { + if containsRecord(r, result) == nil { + t.Fatalf("record %+v was not created", r) + } } - if r.Type != c.expected[k].Type { - t.Fatalf("r.Type != c.exptected[%d].Type => %s != %s", k, r.Type, c.expected[k].Type) + // each created record must have an ID + for _, r := range result { + if r.ID == "" { + t.Fatalf("no ID set in result %+v", r) + } } - if r.Name != c.expected[k].Name { - t.Fatalf("r.Name != c.exptected[%d].Name => %s != %s", k, r.Name, c.expected[k].Name) + }) + } +} + +func Test_DeleteRecords(t *testing.T) { + p := &ionos.Provider{AuthAPIToken: envToken} + + // run the test 2 times: first delete with the record ID, second run without + for _, clearID := range []bool{true, false} { + t.Run(fmt.Sprintf("clear record.ID=%v", clearID), + func(t *testing.T) { + // create a random TXT record + name := randSeq(32) + records := []libdns.Record{{Type: "TXT", Name: name, Value: "my record", TTL: ttl}} + records, err := p.SetRecords(context.TODO(), envZone, records) + if err != nil { + t.Fatal(err) } - if r.Value != c.expected[k].Value { - t.Fatalf("r.Value != c.exptected[%d].Value => %s != %s", k, r.Value, c.expected[k].Value) + //defer cleanupRecords(t, p, slices.Clone(records)) + if len(records) != 1 { + t.Fatalf("expected only 1 record to be created, but got %d", len(records)) } - if r.TTL != c.expected[k].TTL { - t.Fatalf("r.TTL != c.exptected[%d].TTL => %s != %s", k, r.TTL, c.expected[k].TTL) + + // make sure the record exists in the zone + allRecords, err := p.GetRecords(context.TODO(), envZone) + if err != nil { + t.Fatal(err) } - } - }() + checkExcatlyOneRecordExists(t, allRecords, "TXT", name, "my record") + + // test with- and without a recordID + if clearID { + records[0].ID = "" + } + records, err = p.DeleteRecords(context.TODO(), envZone, records) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("expected only 1 record to be deleted, but got %d", len(records)) + } + + // make sure the record is no longer in the zone + allRecords, err = p.GetRecords(context.TODO(), envZone) + if err != nil { + t.Fatal(err) + } + checkNoRecordExists(t, allRecords, name) + }) } } -func Test_DeleteRecords(t *testing.T) { - p := &ionos.Provider{ - AuthAPIToken: envToken, - } +func Test_DeleteRecordsWillNotDeleteWithoutTypeOrNameWhenNoIDisGiven(t *testing.T) { + p := &ionos.Provider{AuthAPIToken: envToken} - testRecords, cleanupFunc := setupTestRecords(t, p) - defer cleanupFunc() + records := []libdns.Record{ + {ID: "", Type: "TXT", Name: "", Value: "", TTL: ttl}, + {ID: "", Type: "", Name: "X", Value: "", TTL: ttl}, + {ID: "", Type: "", Name: "", Value: "", TTL: ttl}} - records, err := p.GetRecords(context.TODO(), envZone) + records, err := p.DeleteRecords(context.TODO(), envZone, records) if err != nil { t.Fatal(err) } - - if len(records) < len(testRecords) { - t.Fatalf("len(records) < len(testRecords) => %d < %d", len(records), len(testRecords)) + if len(records) != 0 { + t.Fatalf("expected no record to be deleted, but got %d", len(records)) } - for _, testRecord := range testRecords { - var foundRecord *libdns.Record - for _, record := range records { - if testRecord.ID == record.ID { - foundRecord = &testRecord - } - } - - if foundRecord == nil { - t.Fatalf("Record not found => %s", testRecord.ID) - } - } } +// Test_GetRecords creates some records and checks using GetRecords that +// the records are returned as expected func Test_GetRecords(t *testing.T) { - p := &ionos.Provider{ - AuthAPIToken: envToken, - } - - testRecords, cleanupFunc := setupTestRecords(t, p) - defer cleanupFunc() - - records, err := p.GetRecords(context.TODO(), envZone) + p := &ionos.Provider{AuthAPIToken: envToken} + + // create some test records + prefix := randSeq(32) + records := []libdns.Record{ + {Type: "TXT", Name: prefix + "_test_1", Value: "val_1", TTL: ttl}, + {Type: "A", Name: prefix + "_test_2", Value: "1.2.3.4", TTL: ttl}} + created, err := p.AppendRecords(context.TODO(), envZone, records) if err != nil { t.Fatal(err) } + if len(created) != len(records) { + t.Fatalf("expected %d records to be created, got %d", len(records), len(created)) + } + defer cleanupRecords(t, p, created) - if len(records) < len(testRecords) { - t.Fatalf("len(records) < len(testRecords) => %d < %d", len(records), len(testRecords)) + // read all records of the zone and check that our records are contained + allRecords, err := p.GetRecords(context.TODO(), envZone) + if len(allRecords) < len(records) { + t.Fatalf("expected to read at least %d records from zone, but got %d", len(records), len(allRecords)) } - for _, testRecord := range testRecords { - var foundRecord *libdns.Record - for _, record := range records { - if testRecord.ID == record.ID { - foundRecord = &testRecord - } + for _, r := range created { + found := containsRecord(r, allRecords) + if found == nil { + t.Fatalf("Record %s not found", r.ID) } - - if foundRecord == nil { - t.Fatalf("Record not found => %s", testRecord.ID) + if found.ID != r.ID { + t.Fatalf("Record found but ID differs (%s != %s)", r.ID, found.ID) } } } -func Test_SetRecords(t *testing.T) { - p := &ionos.Provider{ - AuthAPIToken: envToken, - } +func Test_UpdateRecords(t *testing.T) { + p := &ionos.Provider{AuthAPIToken: envToken} + + // run the test 2 times: first delete with the record ID, second run without + for _, clearID := range []bool{true, false} { + t.Run(fmt.Sprintf("clear record.ID=%v", clearID), + func(t *testing.T) { + // create a random A record + name := randSeq(32) + records := []libdns.Record{{Type: "A", Name: name, Value: "1.2.3.4", TTL: ttl}} + records, err := p.SetRecords(context.TODO(), envZone, records) + if err != nil { + t.Fatal(err) + } + defer cleanupRecords(t, p, slices.Clone(records)) - existingRecords, _ := setupTestRecords(t, p) - newTestRecords := []libdns.Record{ - { - Type: "TXT", - Name: "new_test1", - Value: "new_test1", - TTL: ttl, - }, - { - Type: "TXT", - Name: "new_test2", - Value: "new_test2", - TTL: ttl, - }, - { - Type: "TXT", - Name: "new_test3", - Value: "new_test3", - TTL: 0, - }, - } + if len(records) != 1 { + t.Fatalf("expected only 1 record to be created, but got %d", len(records)) + } - allRecords := append(existingRecords, newTestRecords...) - allRecords[0].Value = "new_value" + // test with- and without a recordID + if clearID { + records[0].ID = "" + } + records[0].Value = "1.2.3.5" + records, err = p.SetRecords(context.TODO(), envZone, records) + if len(records) != 1 { + t.Fatalf("expected only 1 record to be updated, but got %d", len(records)) + } - records, err := p.SetRecords(context.TODO(), envZone, allRecords) - if err != nil { - t.Fatal(err) + // read all records and check for the expected changes + records, err = p.GetRecords(context.TODO(), envZone) + if err != nil { + t.Fatal(err) + } + checkExcatlyOneRecordExists(t, records, "A", name, "1.2.3.5") + }) } - defer cleanupRecords(t, p, records) +} - if len(records) != len(allRecords) { - t.Fatalf("len(records) != len(allRecords) => %d != %d", len(records), len(allRecords)) - } +func TestMain(m *testing.M) { + envToken = os.Getenv("LIBDNS_IONOS_TEST_TOKEN") + envZone = os.Getenv("LIBDNS_IONOS_TEST_ZONE") - if records[0].Value != "new_value" { - t.Fatalf(`records[0].Value != "new_value" => %s != "new_value"`, records[0].Value) + if len(envToken) == 0 || len(envZone) == 0 { + fmt.Println(`Please notice that this test runs agains the public ionos DNS Api, so you sould +never run the test with a zone, used in production. +To run this test, you have to specify 'LIBDNS_IONOS_TEST_TOKEN' and 'LIBDNS_IONOS_TEST_ZONE'. +Example: "LIBDNS_IONOS_TEST_TOKEN="123.456" LIBDNS_IONOS_TEST_ZONE="my-domain.com" go test ./... -v`) + os.Exit(1) } + + os.Exit(m.Run()) }