From c8896c4a03ce8351551dc869aa4bd93dd1b516e4 Mon Sep 17 00:00:00 2001 From: Timo Behrendt Date: Sat, 10 Jan 2026 20:42:55 +0100 Subject: [PATCH] refactor: to use ionosDnsClient --- config.example.yaml | 4 + go.mod | 4 +- go.sum | 15 +- pkg/dnsProvider/ionos/api/ionosAPI.go | 224 --- pkg/dnsProvider/ionos/api/ionosAPI_test.go | 285 ---- pkg/dnsProvider/ionos/ionos.go | 244 +++- pkg/dnsProvider/ionos/ionos_test.go | 1459 ++++++++++++++++++-- 7 files changed, 1602 insertions(+), 633 deletions(-) delete mode 100644 pkg/dnsProvider/ionos/api/ionosAPI.go delete mode 100644 pkg/dnsProvider/ionos/api/ionosAPI_test.go diff --git a/config.example.yaml b/config.example.yaml index 71ce76e..259df8f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,6 +8,10 @@ dns_provider: config: api_key: base_url: https://api.hosting.ionos.com/dns + # Optional: default TTL to use when patching records with missing TTL (default: 300) + # default_ttl: 300 + # Optional: default priority to use when patching records with missing priority (default: 0) + # default_prio: 0 notification_provider: type: gotify config: diff --git a/go.mod b/go.mod index 6955bac..bdd07f0 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,15 @@ module realdnydns go 1.25.0 require ( + gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2 github.com/go-co-op/gocron v1.37.0 - github.com/go-co-op/gocron/v2 v2.19.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/oapi-codegen/runtime v1.1.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/stretchr/testify v1.11.1 // indirect go.uber.org/atomic v1.11.0 // indirect diff --git a/go.sum b/go.sum index f4d8596..c261120 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,19 @@ +gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2 h1:EWz4kLLv7lSZx/F8K0/WxN5xeVNh7z4qbeDurZYBcNk= +gitea.t000-n.de/t.behrendt/ionosDnsClient v1.0.2/go.mod h1:HZfdMF7X9LK/3FP1jJVzMWbM5BcgcZg/Qwx2WClFipg= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= -github.com/go-co-op/gocron/v2 v2.18.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI= -github.com/go-co-op/gocron/v2 v2.19.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -17,6 +22,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -25,6 +32,7 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -32,8 +40,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= diff --git a/pkg/dnsProvider/ionos/api/ionosAPI.go b/pkg/dnsProvider/ionos/api/ionosAPI.go deleted file mode 100644 index 2289c67..0000000 --- a/pkg/dnsProvider/ionos/api/ionosAPI.go +++ /dev/null @@ -1,224 +0,0 @@ -package ionosAPI - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net" - "net/http" - "net/url" - "realdnydns/model/common" -) - -/** - * Docs: https://developer.hosting.ionos.com/docs/dns - */ -type IonosAPI interface { - GetARecord(tld string, subdomain string) (*common.ARecord, error) - SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) - GetZoneId(tld string) (string, error) - GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) - HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error) -} - -type IonosAPIImpl struct { - APIKey string - BaseURL string - HTTPClient *http.Client -} - -type ZonesResponse []struct { - Name string `json:"name"` - Id string `json:"id"` - Type string `json:"type"` -} - -type ZoneResponse struct { - Id string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Records []RecordResponse `json:"records"` -} - -type RecordResponse struct { - Id string `json:"id"` - Name string `json:"name"` - RootName string `json:"rootName"` - Type string `json:"type"` - Content string `json:"content"` - ChangeDate string `json:"changeDate"` - TTL int `json:"ttl"` - Prio int `json:"prio"` - Disabled bool `json:"disabled"` -} - -type ChangeRecord struct { - Name string `json:"name"` - Type string `json:"type"` - Content string `json:"content"` - TTL int `json:"ttl"` - Prio int `json:"prio"` - Disabled bool `json:"disabled"` -} - -type ChangeRecordRequest struct { - Content string `json:"content"` - TTL int `json:"ttl"` - Prio int `json:"prio"` - Disabled bool `json:"disabled"` -} - -func New(APIKey string, BaseURL string) IonosAPI { - return &IonosAPIImpl{ - APIKey: APIKey, - BaseURL: BaseURL, - HTTPClient: &http.Client{}, - } -} - -func (i *IonosAPIImpl) HttpCall(method string, path string, body io.Reader, queryParams map[string]string) (*http.Response, error) { - requestUrl, _ := url.Parse(i.BaseURL + path) - - query := requestUrl.Query() - for key, value := range queryParams { - query.Add(key, value) - } - requestUrl.RawQuery = query.Encode() - - req, err := http.NewRequest(method, requestUrl.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("X-API-Key", i.APIKey) - req.Header.Add("Content-Type", "application/json") - req.Header.Add("Accept", "application/json") - - return i.HTTPClient.Do(req) -} - -func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) { - res, err := i.HttpCall("GET", "/v1/zones", nil, nil) - if err != nil { - return "", err - } - - responseBody := make([]byte, res.ContentLength) - res.Body.Read(responseBody) - - zones := []ZoneResponse{} - json.Unmarshal(responseBody, &zones) - - for _, z := range zones { - if z.Name == tld { - return z.Id, nil - } - } - - return "", errors.New("zone not found") -} - -func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) { - var domain string - if subdomain == "@" || subdomain == "" { - domain = tld - } else { - domain = subdomain + "." + tld - } - - res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil, map[string]string{"recordName": domain, "recordType": recordType}) - if err != nil { - return "", err - } - - responseBody := make([]byte, res.ContentLength) - res.Body.Read(responseBody) - - zone := ZoneResponse{} - json.Unmarshal(responseBody, &zone) - - for _, record := range zone.Records { - if record.Type == recordType && record.Name == domain { - return record.Id, nil - } - } - - return "", errors.New("record not found") -} - -func (i *IonosAPIImpl) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { - zoneId, err := i.GetZoneId(tld) - if err != nil { - return nil, err - } - - recordId, err := i.GetRecordId(zoneId, tld, subdomain, "A") - if err != nil { - return nil, err - } - - changeRecordRequest, err := json.Marshal(ChangeRecordRequest{ - Content: ip.String(), - TTL: ttl, - Prio: prio, - Disabled: false, - }) - if err != nil { - return nil, err - } - - res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest), nil) - if err != nil { - return nil, err - } - - responseBody := make([]byte, res.ContentLength) - res.Body.Read(responseBody) - - if res.StatusCode != 200 { - return nil, errors.New("error updating record") - } - - changeRecord := ChangeRecord{} - json.Unmarshal(responseBody, &changeRecord) - - return &common.ARecord{ - Domain: changeRecord.Name, - IP: changeRecord.Content, - TTL: changeRecord.TTL, - Prio: changeRecord.Prio, - Disabled: changeRecord.Disabled, - }, nil -} - -func (ionos *IonosAPIImpl) GetARecord(tld string, subdomain string) (*common.ARecord, error) { - zoneId, err := ionos.GetZoneId(tld) - if err != nil { - return nil, err - } - - recordId, err := ionos.GetRecordId(zoneId, tld, subdomain, "A") - if err != nil { - return nil, err - } - - res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil, nil) - if err != nil { - return nil, err - } - - responseBody := make([]byte, res.ContentLength) - res.Body.Read(responseBody) - - record := RecordResponse{} - json.Unmarshal(responseBody, &record) - - return &common.ARecord{ - Domain: record.Name, - IP: record.Content, - TTL: record.TTL, - Prio: record.Prio, - Disabled: record.Disabled, - }, nil -} diff --git a/pkg/dnsProvider/ionos/api/ionosAPI_test.go b/pkg/dnsProvider/ionos/api/ionosAPI_test.go deleted file mode 100644 index bb7806a..0000000 --- a/pkg/dnsProvider/ionos/api/ionosAPI_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package ionosAPI - -import ( - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "testing" -) - -func utilMockServerImpl() func(w http.ResponseWriter, r *http.Request) { - zonesResponse := []ZoneResponse{ - { - Name: "example.com", - Id: "1234567890", - Type: "NATIVE", - }, - { - Name: "notTheExample.org", - Id: "0987654321", - Type: "SLAVE", - }, - } - zonesResponseJson, _ := json.Marshal(zonesResponse) - - recordResponseSub := RecordResponse{ - Id: "abcdefghij", - Name: "example.com", - RootName: "example.com", - Type: "A", - Content: "127.0.0.1", - ChangeDate: "2019-12-09T13:04:25.772Z", - TTL: 300, - Prio: 0, - Disabled: false, - } - recordResponseSubJson, _ := json.Marshal(recordResponseSub) - - recordResponseTLD := RecordResponse{ - Id: "jihgfedcba", - Name: "sub.example.com", - RootName: "example.com", - Type: "A", - Content: "127.0.0.2", - ChangeDate: "2019-12-09T13:04:25.772Z", - TTL: 300, - Prio: 0, - Disabled: false, - } - recordResponseTLDJson, _ := json.Marshal(recordResponseTLD) - - zoneResponse := ZoneResponse{ - Id: "1234567890", - Name: "example.com", - Type: "NATIVE", - Records: []RecordResponse{ - recordResponseSub, - recordResponseTLD, - }, - } - zoneResponseJson, _ := json.Marshal(zoneResponse) - - changeRecord := ChangeRecord{ - Name: "sub.example.com", - Type: "A", - Content: "127.0.0.1", - TTL: 300, - Prio: 0, - Disabled: false, - } - changeRecordJson, _ := json.Marshal(changeRecord) - - return func(w http.ResponseWriter, r *http.Request) { - var response []byte - - if r.Method == "GET" { - if r.RequestURI == "/v1/zones" { - response = zonesResponseJson - } else if r.RequestURI == "/v1/zones/1234567890?recordName=example.com&recordType=A" || - r.RequestURI == "/v1/zones/1234567890?recordName=sub.example.com&recordType=A" { - response = zoneResponseJson - } else if r.RequestURI == "/v1/zones/1234567890/records/abcdefghij" { - response = recordResponseSubJson - } else if r.RequestURI == "/v1/zones/1234567890/records/jihgfedcba" { - response = recordResponseTLDJson - } - } else if r.Method == "PUT" { - response = changeRecordJson - } else { - response = []byte("404") - } - - w.Write(response) - } -} - -func TestHttpCall(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) - defer mockServer.Close() - - ionosAPI := New("dummyKey", mockServer.URL) - - t.Run("returns response for GET request", testHttpCallGet(ionosAPI)) - t.Run("returns response for PUT request", testHttpCallPut(ionosAPI)) - t.Run("returns error for non existing endpoint", testHttpCallNonExistingEndpoint()) -} - -func testHttpCallGet(api IonosAPI) func(t *testing.T) { - return func(t *testing.T) { - res, err := api.HttpCall("GET", "/v1/zones", nil, nil) - - if err != nil { - t.Fatalf("HttpCall() returned unexpected error: %v", err) - } - - if res.StatusCode != 200 { - t.Fatalf("HttpCall() returned unexpected status code: %v instead of 200", res.StatusCode) - } - } -} - -func testHttpCallPut(api IonosAPI) func(t *testing.T) { - return func(t *testing.T) { - res, err := api.HttpCall("PUT", "/v1/zones/1234567890/records/abcdefghij", nil, nil) - - if err != nil { - t.Fatalf("HttpCall() returned unexpected error: %v", err) - } - - if res.StatusCode != 200 { - t.Fatalf("HttpCall() returned unexpected status code: %v instead of 200", res.StatusCode) - } - } -} - -func testHttpCallNonExistingEndpoint() func(t *testing.T) { - return func(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - })) - defer mockServer.Close() - - api := New("dummyKey", mockServer.URL) - - res, err := api.HttpCall("GET", "/v1/nonExistingEndpoint", nil, nil) - - if err != nil { - t.Fatalf("HttpCall() returned unexpected error: %v", err) - } - - if res.StatusCode != 404 { - t.Fatalf("HttpCall() returned unexpected status code: %v instead of 404", res.StatusCode) - } - } -} - -func TestGetZoneId(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) - - ionosAPI := New("dummyKey", mockServer.URL) - - t.Run("returns zoneId for tracked domain", testGetZoneIdSuccess(ionosAPI)) - t.Run("returns error for non tracked domain", testGetZoneIdNoMatch(ionosAPI)) -} - -func testGetZoneIdSuccess(api IonosAPI) func(t *testing.T) { - return func(t *testing.T) { - zoneId, err := api.GetZoneId("example.com") - - if err != nil { - t.Fatalf("GetZoneId() returned unexpected error: %v", err) - } - - if zoneId != "1234567890" { - t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of 1234567890", zoneId) - } - } -} - -func testGetZoneIdNoMatch(api IonosAPI) func(t *testing.T) { - return func(t *testing.T) { - zoneId, err := api.GetZoneId("nonTrackedDomain.com") - - if err == nil || zoneId != "" { - t.Fatalf("GetZoneId() did not return an error for a non tracked domain") - } - } -} - -func TestGetRecordId(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) - defer mockServer.Close() - - ionosAPI := New("dummyKey", mockServer.URL) - - t.Run("returns zoneId for top level domain", testGetRecordIdForTopLevelDomain(ionosAPI)) - t.Run("returns zoneId for subdomain", testGetRecordIdForSubdomain(ionosAPI)) - t.Run("returns error for untracked subdomain", testGetRecordIdWithUntrackedSubdomain(ionosAPI)) -} - -func testGetRecordIdForTopLevelDomain(api IonosAPI) func(t *testing.T) { - return func(t *testing.T) { - recordId, err := api.GetRecordId("1234567890", "example.com", "", "A") - - if err != nil { - t.Fatalf("GetZoneId() returned unexpected error: %v", err) - } - - if recordId != "abcdefghij" { - t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of abcdefghij", recordId) - } - } -} - -func testGetRecordIdForSubdomain(api IonosAPI) func(t *testing.T) { - return func(t *testing.T) { - recordId, err := api.GetRecordId("1234567890", "example.com", "sub", "A") - - if err != nil { - t.Fatalf("GetZoneId() returned unexpected error: %v", err) - } - - if recordId != "jihgfedcba" { - t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of jihgfedcba", recordId) - } - } -} - -func testGetRecordIdWithUntrackedSubdomain(api IonosAPI) func(t *testing.T) { - return func(t *testing.T) { - recordId, err := api.GetRecordId("1234567890", "example.com", "untrackedSub", "A") - - if err == nil && recordId == "" { - t.Fatalf("GetZoneId() did not return an error for a non tracked domain") - } - } -} - -func TestGetARecord(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) - defer mockServer.Close() - - ionosAPI := New("dummyKey", mockServer.URL) - - t.Run("returns A record for top level domain", testGetARecordFor(ionosAPI, "example.com", "")) - t.Run("returns A record for subdomain", testGetARecordFor(ionosAPI, "example.com", "sub")) -} - -func testGetARecordFor(api IonosAPI, tld string, subdomain string) func(t *testing.T) { - return func(t *testing.T) { - var domain string - if subdomain == "" { - domain = tld - } else { - domain = subdomain + "." + tld - } - - record, err := api.GetARecord(tld, subdomain) - - if err != nil { - t.Fatalf("GetARecord() returned unexpected error: %v", err) - } - - if record.Domain != domain { - t.Fatalf("GetARecord() returned unexpected record for: %v instead of %v", record.Domain, domain) - } - } -} - -func TestSetARecord(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) - defer mockServer.Close() - - ionosAPI := New("dummyKey", mockServer.URL) - - changedARecord, err := ionosAPI.SetARecord("example.com", "sub", net.ParseIP("127.0.0.1"), 300, 0, false) - - if err != nil { - t.Fatalf("SetARecord() returned unexpected error: %v", err) - } - - if changedARecord.Domain != "sub.example.com" { - t.Fatalf("SetARecord() returned unexpected record for: %v instead of sub.example.com", changedARecord.Domain) - } -} diff --git a/pkg/dnsProvider/ionos/ionos.go b/pkg/dnsProvider/ionos/ionos.go index 11249a4..3c645f7 100644 --- a/pkg/dnsProvider/ionos/ionos.go +++ b/pkg/dnsProvider/ionos/ionos.go @@ -1,20 +1,30 @@ package ionosDnsProvider import ( + "context" "errors" "net" + "net/http" "realdnydns/model/common" "realdnydns/pkg/dnsProvider" - ionosAPI "realdnydns/pkg/dnsProvider/ionos/api" + "sync" + + ionosDnsClient "gitea.t000-n.de/t.behrendt/ionosDnsClient" ) type IONOS struct { - API ionosAPI.IonosAPI + client *ionosDnsClient.ClientWithResponses + zoneIdMap map[string]string + defaultTtl int + defaultPrio int + zoneIdMapMutex sync.Mutex } type IONOSConfig struct { - APIKey string `yaml:"api_key"` - BaseURL string `yaml:"base_url"` + APIKey string `yaml:"api_key"` + BaseURL string `yaml:"base_url"` + DefaultTTL *int `yaml:"default_ttl,omitempty"` + DefaultPrio *int `yaml:"default_prio,omitempty"` } func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) { @@ -26,15 +36,231 @@ func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) { return nil, errors.New("base_url is required") } + options := []ionosDnsClient.ClientOption{ + ionosDnsClient.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error { + req.Header.Set("X-API-Key", config.APIKey) + return nil + }), + } + + client, err := ionosDnsClient.NewClientWithResponses(config.BaseURL, options...) + if err != nil { + return nil, err + } + + // Set default values for TTL and Prio if not provided + defaultTtl := 300 + if config.DefaultTTL != nil { + defaultTtl = *config.DefaultTTL + } + + defaultPrio := 0 + if config.DefaultPrio != nil { + defaultPrio = *config.DefaultPrio + } + return &IONOS{ - ionosAPI.New(config.APIKey, config.BaseURL), + client: client, + zoneIdMap: make(map[string]string), + defaultTtl: defaultTtl, + defaultPrio: defaultPrio, }, nil } -func (ionos *IONOS) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { - return ionos.API.SetARecord(tld, subdomain, ip, ttl, prio, disabled) +func (i *IONOS) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { + + zoneId, err := i.getZoneIdForTld(tld) + if err != nil { + return nil, err + } + + domain := assembleTldSubdomainToDomain(tld, subdomain) + recordId, err := i.getRecordIdForDomain(zoneId, domain) + if err != nil { + return nil, err + } + + ipString := ip.String() + res, err := i.client.UpdateRecordWithResponse(context.Background(), zoneId, recordId, ionosDnsClient.RecordUpdate{ + Content: &ipString, + Ttl: &ttl, + Prio: &prio, + Disabled: &disabled, + }) + if err != nil || res.StatusCode() != 200 { + return nil, errors.New("failed to update record") + } + + if res.JSON200 == nil { + return nil, errors.New("record response is empty") + } + + record := *res.JSON200 + if record.Name == nil || record.Content == nil { + return nil, errors.New("record is empty") + } + + // Handle optional fields with defaults + recordTtl := 0 + if record.Ttl != nil { + recordTtl = *record.Ttl + } + + recordPrio := 0 + if record.Prio != nil { + recordPrio = *record.Prio + } + + recordDisabled := false + if record.Disabled != nil { + recordDisabled = *record.Disabled + } + + return &common.ARecord{ + Domain: *record.Name, + IP: *record.Content, + TTL: recordTtl, + Prio: recordPrio, + Disabled: recordDisabled, + }, nil } -func (ionos *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) { - return ionos.API.GetARecord(tld, subdomain) +func (i *IONOS) getZoneIdForTld(tld string) (string, error) { + i.zoneIdMapMutex.Lock() + defer i.zoneIdMapMutex.Unlock() + if zoneId, ok := i.zoneIdMap[tld]; ok { + return zoneId, nil + } + + res, err := i.client.GetZonesWithResponse(context.Background()) + if err != nil { + return "", err + } else if res.StatusCode() != 200 { + return "", errors.New("failed to get zones") + } + + if res.JSON200 == nil { + return "", errors.New("zones response is empty") + } + + zones := *res.JSON200 + + for _, zone := range zones { + if *zone.Name == tld { + if zone.Id == nil || *zone.Id == "" { + return "", errors.New("zone id is empty") + } + + i.zoneIdMap[tld] = *zone.Id + return *zone.Id, nil + } + } + + return "", errors.New("no zone found") +} + +var recordTypeA = "A" + +func assembleTldSubdomainToDomain(tld string, subdomain string) string { + if subdomain == "@" || subdomain == "" { + return tld + } + return subdomain + "." + tld +} + +func (i *IONOS) getRecordIdForDomain(zoneId string, domain string) (string, error) { + res, err := i.client.GetZoneWithResponse(context.Background(), zoneId, &ionosDnsClient.GetZoneParams{ + RecordName: &domain, + RecordType: &recordTypeA, + }) + if err != nil || res.StatusCode() != 200 { + return "", errors.New("failed to get zone") + } + + if res.JSON200 == nil { + return "", errors.New("zone response is empty") + } + + zone := *res.JSON200 + + for _, record := range *zone.Records { + if *record.Name == domain && *record.Type == ionosDnsClient.RecordTypes("A") { + return *record.Id, nil + } + } + + return "", errors.New("no record found") +} + +func (i *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) { + zoneId, err := i.getZoneIdForTld(tld) + if err != nil { + return nil, err + } + + domain := assembleTldSubdomainToDomain(tld, subdomain) + recordId, err := i.getRecordIdForDomain(zoneId, domain) + if err != nil { + return nil, err + } + + res, err := i.client.GetRecordWithResponse(context.Background(), zoneId, recordId) + if err != nil || res.StatusCode() != 200 || res.JSON200 == nil { + return nil, errors.New("failed to get record") + } + + record := *res.JSON200 + + if record.Name == nil || *record.Name == "" || *record.Name != domain { + return nil, errors.New("record name does not match or is empty") + } + + if record.Content == nil || *record.Content == "" { + return nil, errors.New("record content is empty") + } + + needsUpdate := false + + ttl := i.defaultTtl + if record.Ttl != nil { + ttl = *record.Ttl + } else { + needsUpdate = true + } + + prio := i.defaultPrio + if record.Prio != nil { + prio = *record.Prio + } else { + needsUpdate = true + } + + disabled := false + if record.Disabled != nil { + disabled = *record.Disabled + } else { + needsUpdate = true + } + + // realDynDns requires every field to be set, so we need to update the record if any of the fields are missing + if needsUpdate { + ip := net.ParseIP(*record.Content) + if ip == nil { + return nil, errors.New("invalid IP address in record content") + } + + updatedRecord, err := i.UpdateRecord(tld, subdomain, ip, ttl, prio, disabled) + if err != nil { + return nil, err + } + return updatedRecord, nil + } + + return &common.ARecord{ + Domain: *record.Name, + IP: *record.Content, + TTL: ttl, + Prio: prio, + Disabled: disabled, + }, nil } diff --git a/pkg/dnsProvider/ionos/ionos_test.go b/pkg/dnsProvider/ionos/ionos_test.go index ef51297..bcc5f14 100644 --- a/pkg/dnsProvider/ionos/ionos_test.go +++ b/pkg/dnsProvider/ionos/ionos_test.go @@ -1,20 +1,19 @@ package ionosDnsProvider_test import ( - "errors" - "io" + "encoding/json" "net" "net/http" - common "realdnydns/model/common" + "net/http/httptest" ionosDnsProvider "realdnydns/pkg/dnsProvider/ionos" "testing" ) func TestNew(t *testing.T) { - //t.Run("Config cannot be decoded", testNewConfigCannotBeDecoded()) t.Run("API key is required", testNewAPIKeyIsRequired()) t.Run("Base URL is required", testNewBaseURLIsRequired()) t.Run("Valid config", testNewValidConfig()) + t.Run("Valid config with custom defaults", testNewValidConfigWithCustomDefaults()) } func testNewAPIKeyIsRequired() func(*testing.T) { @@ -40,15 +39,19 @@ func testNewBaseURLIsRequired() func(*testing.T) { if err == nil { t.Error("Expected error, got nil") } - } } func testNewValidConfig() func(*testing.T) { return func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer mockServer.Close() + config := ionosDnsProvider.IONOSConfig{ APIKey: "1234", - BaseURL: "https://api.ionos.com", + BaseURL: mockServer.URL, } _, err := ionosDnsProvider.NewIonos(&config) @@ -58,87 +61,1056 @@ func testNewValidConfig() func(*testing.T) { } } -type MockIonosAPI struct { - SetARecordFunc func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) - GetARecordFunc func(tld string, subdomain string) (*common.ARecord, error) - GetRecordIdFunc func(zoneId string, tld string, subdomain string, recordType string) (string, error) - GetZoneIdFunc func(tld string) (string, error) - HttpCallFunc func(method string, url string, body io.Reader) (*http.Response, error) -} - -func (m *MockIonosAPI) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { - return m.SetARecordFunc(tld, subdomain, ip, ttl, prio, disabled) -} - -func (m *MockIonosAPI) GetARecord(tld string, subdomain string) (*common.ARecord, error) { - return m.GetARecordFunc(tld, subdomain) -} - -func (m *MockIonosAPI) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) { - return m.GetRecordIdFunc(zoneId, tld, subdomain, recordType) -} - -func (m *MockIonosAPI) GetZoneId(tld string) (string, error) { - return m.GetZoneIdFunc(tld) -} - -func (m *MockIonosAPI) HttpCall(method string, url string, body io.Reader, queryParams map[string]string) (*http.Response, error) { - return m.HttpCallFunc(method, url, body) -} - -func TestUpdateRecord(t *testing.T) { - t.Run("API error", testUpdateRecordAPIError()) - t.Run("API success", testUpdateRecordAPISuccess()) -} - -func testUpdateRecordAPIError() func(*testing.T) { +func testNewValidConfigWithCustomDefaults() func(*testing.T) { return func(t *testing.T) { - api := &MockIonosAPI{ - SetARecordFunc: func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { - return nil, errors.New("API error") + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer mockServer.Close() + + customTTL := 600 + customPrio := 10 + config := ionosDnsProvider.IONOSConfig{ + APIKey: "1234", + BaseURL: mockServer.URL, + DefaultTTL: &customTTL, + DefaultPrio: &customPrio, + } + + _, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + } +} + +// Helper function to create a mock server that responds to IONOS API calls +func createMockServer() *httptest.Server { + zoneId := "1234567890" + recordIdSub := "abcdefghij" + recordIdTLD := "jihgfedcba" + + zonesResponse := []map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + { + "name": "notTheExample.org", + "id": "0987654321", + "type": "SLAVE", + }, + } + + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordIdTLD, + "name": "example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": 300, + "prio": 0, + "disabled": false, }, + { + "id": recordIdSub, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.2", + "ttl": 300, + "prio": 0, + "disabled": false, + }, + }, + } + + recordResponseSub := map[string]any{ + "id": recordIdSub, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.2", + "ttl": 300, + "prio": 0, + "disabled": false, + } + + recordResponseTLD := map[string]any{ + "id": recordIdTLD, + "name": "example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": 300, + "prio": 0, + "disabled": false, + } + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method == "GET" { + switch r.URL.Path { + case "/v1/zones": + json.NewEncoder(w).Encode(zonesResponse) + case "/v1/zones/" + zoneId: + // Check query parameters + recordName := r.URL.Query().Get("recordName") + recordType := r.URL.Query().Get("recordType") + if recordName != "" && recordType == "A" { + json.NewEncoder(w).Encode(zoneResponse) + } else { + w.WriteHeader(400) + } + case "/v1/zones/" + zoneId + "/records/" + recordIdSub: + json.NewEncoder(w).Encode(recordResponseSub) + case "/v1/zones/" + zoneId + "/records/" + recordIdTLD: + json.NewEncoder(w).Encode(recordResponseTLD) + default: + w.WriteHeader(404) + } + } else if r.Method == "PUT" { + switch r.URL.Path { + case "/v1/zones/" + zoneId + "/records/" + recordIdSub: + // Return updated record + json.NewEncoder(w).Encode(recordResponseSub) + case "/v1/zones/" + zoneId + "/records/" + recordIdTLD: + // Return updated record + json.NewEncoder(w).Encode(recordResponseTLD) + default: + w.WriteHeader(404) + } + } else { + w.WriteHeader(404) + } + })) +} + +func TestGetRecord(t *testing.T) { + t.Run("Success for subdomain", testGetRecordSuccessSubdomain()) + t.Run("Success for TLD", testGetRecordSuccessTLD()) + t.Run("Success for @ subdomain", testGetRecordSuccessAtSymbol()) + t.Run("Error when zone not found", testGetRecordZoneNotFound()) + t.Run("Error when record not found", testGetRecordRecordNotFound()) + t.Run("Error when API call fails", testGetRecordAPICallFails()) + t.Run("Error when record name mismatch", testGetRecordNameMismatch()) + t.Run("Error when GetZonesWithResponse returns non-200", testGetRecordGetZonesWithResponseNon200()) + t.Run("Error when zone id is nil", testGetRecordZoneIdNil()) + t.Run("Error when zone id is empty", testGetRecordZoneIdEmpty()) + t.Run("Error when GetRecordWithResponse returns non-200", testGetRecordGetRecordWithResponseNon200()) + t.Run("Error when record content is nil", testGetRecordContentNil()) + t.Run("Error when record content is empty", testGetRecordContentEmpty()) + t.Run("Success when record ttl is nil (uses default)", testGetRecordTtlNil()) + t.Run("Success when record prio is nil (uses default)", testGetRecordPrioNil()) + t.Run("Success when record disabled is nil (uses default)", testGetRecordDisabledNil()) +} + +func testGetRecordSuccessSubdomain() func(*testing.T) { + return func(t *testing.T) { + mockServer := createMockServer() + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, } - provider := ionosDnsProvider.IONOS{ - API: api, + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) } - _, err := provider.UpdateRecord("example.com", "sub", net.ParseIP("127.0.0.1"), 60, 0, false) + record, err := provider.GetRecord("example.com", "sub") + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + + if record.Domain != "sub.example.com" { + t.Errorf("Expected sub.example.com, got %s", record.Domain) + } + + if record.IP != "127.0.0.2" { + t.Errorf("Expected 127.0.0.2, got %s", record.IP) + } + + if record.TTL != 300 { + t.Errorf("Expected 300, got %d", record.TTL) + } + + if record.Prio != 0 { + t.Errorf("Expected 0, got %d", record.Prio) + } + + if record.Disabled { + t.Error("Expected false, got true") + } + } +} + +func testGetRecordSuccessTLD() func(*testing.T) { + return func(t *testing.T) { + mockServer := createMockServer() + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + record, err := provider.GetRecord("example.com", "") + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + + if record.Domain != "example.com" { + t.Errorf("Expected example.com, got %s", record.Domain) + } + + if record.IP != "127.0.0.1" { + t.Errorf("Expected 127.0.0.1, got %s", record.IP) + } + } +} + +func testGetRecordSuccessAtSymbol() func(*testing.T) { + return func(t *testing.T) { + mockServer := createMockServer() + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + record, err := provider.GetRecord("example.com", "@") + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + + if record.Domain != "example.com" { + t.Errorf("Expected example.com, got %s", record.Domain) + } + + if record.IP != "127.0.0.1" { + t.Errorf("Expected 127.0.0.1, got %s", record.IP) + } + } +} + +func testGetRecordZoneNotFound() func(*testing.T) { + return func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/zones" { + json.NewEncoder(w).Encode([]map[string]any{}) + } else { + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("nonexistent.com", "sub") if err == nil { t.Error("Expected error, got nil") } } } -func testUpdateRecordAPISuccess() func(*testing.T) { +func testGetRecordRecordNotFound() func(*testing.T) { return func(t *testing.T) { - api := &MockIonosAPI{ - SetARecordFunc: func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { - return &common.ARecord{ - Domain: "sub", - IP: "127.0.0.1", - TTL: 60, - Prio: 0, - Disabled: false, - }, nil - }, + zoneId := "1234567890" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case "/v1/zones/" + zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{}, + } + json.NewEncoder(w).Encode(zoneResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, } - provider := ionosDnsProvider.IONOS{ - API: api, + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) } - record, err := provider.UpdateRecord("example.com", "sub", net.ParseIP("127.0.0.1"), 60, 0, false) + _, err = provider.GetRecord("example.com", "nonexistent") + if err == nil { + t.Error("Expected error, got nil") + } + } +} + +func testGetRecordAPICallFails() func(*testing.T) { + return func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + } +} + +func testGetRecordNameMismatch() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case "/v1/zones/" + zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + case "/v1/zones/" + zoneId + "/records/" + recordId: + // Return record with wrong name + recordResponse := map[string]any{ + "id": recordId, + "name": "wrong.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": 300, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + } +} + +func testGetRecordGetZonesWithResponseNon200() func(*testing.T) { + return func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/zones" { + w.WriteHeader(401) // Unauthorized + } else { + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "failed to get zones" { + t.Errorf("Expected 'failed to get zones', got %v", err) + } + } +} + +func testGetRecordZoneIdNil() func(*testing.T) { + return func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/zones" { + // Return zone with nil id + zonesResponse := []map[string]any{ + { + "name": "example.com", + "id": nil, + "type": "NATIVE", + }, + } + json.NewEncoder(w).Encode(zonesResponse) + } else { + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "zone id is empty" { + t.Errorf("Expected 'zone id is empty', got %v", err) + } + } +} + +func testGetRecordZoneIdEmpty() func(*testing.T) { + return func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/zones" { + // Return zone with empty id + zonesResponse := []map[string]any{ + { + "name": "example.com", + "id": "", + "type": "NATIVE", + }, + } + json.NewEncoder(w).Encode(zonesResponse) + } else { + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "zone id is empty" { + t.Errorf("Expected 'zone id is empty', got %v", err) + } + } +} + +func testGetRecordGetRecordWithResponseNon200() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case "/v1/zones/" + zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + case "/v1/zones/" + zoneId + "/records/" + recordId: + // Return non-200 status + w.WriteHeader(500) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "failed to get record" { + t.Errorf("Expected 'failed to get record', got %v", err) + } + } +} + +func testGetRecordContentNil() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": nil, + "ttl": 300, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "record content is empty" { + t.Errorf("Expected 'record content is empty', got %v", err) + } + } +} + +func testGetRecordContentEmpty() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "", + "ttl": 300, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "record content is empty" { + t.Errorf("Expected 'record content is empty', got %v", err) + } + } +} + +func testGetRecordTtlNil() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": nil, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + case r.Method == "PUT" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + // Return updated record with TTL set + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": 300, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + record, err := provider.GetRecord("example.com", "sub") + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + if record.TTL != 300 { + t.Errorf("Expected TTL to be 300 (default after patch), got %d", record.TTL) + } + if record.IP != "127.0.0.1" { + t.Errorf("Expected IP to be 127.0.0.1, got %s", record.IP) + } + } +} + +func testGetRecordPrioNil() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": 300, + "prio": nil, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + case r.Method == "PUT" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + // Return updated record with Prio set + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": 300, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + record, err := provider.GetRecord("example.com", "sub") + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + if record.Prio != 0 { + t.Errorf("Expected Prio to be 0 (default after patch), got %d", record.Prio) + } + if record.IP != "127.0.0.1" { + t.Errorf("Expected IP to be 127.0.0.1, got %s", record.IP) + } + } +} + +func testGetRecordDisabledNil() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": 300, + "prio": 0, + "disabled": nil, + } + json.NewEncoder(w).Encode(recordResponse) + case r.Method == "PUT" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + // Return updated record with Disabled set + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "127.0.0.1", + "ttl": 300, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + record, err := provider.GetRecord("example.com", "sub") + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + if record.Disabled != false { + t.Errorf("Expected Disabled to be false (default after patch), got %v", record.Disabled) + } + if record.IP != "127.0.0.1" { + t.Errorf("Expected IP to be 127.0.0.1, got %s", record.IP) + } + } +} + +func TestUpdateRecord(t *testing.T) { + t.Run("Success for subdomain", testUpdateRecordSuccessSubdomain()) + t.Run("Success for TLD", testUpdateRecordSuccessTLD()) + t.Run("Error when zone not found", testUpdateRecordZoneNotFound()) + t.Run("Error when record not found", testUpdateRecordRecordNotFound()) + t.Run("Error when UpdateRecordWithResponse returns non-200", testUpdateRecordAPICallFails()) + t.Run("Error when record response is empty", testUpdateRecordEmptyResponse()) +} + +func testUpdateRecordSuccessSubdomain() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + recordName := r.URL.Query().Get("recordName") + if recordName == "sub.example.com" { + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + } else { + w.WriteHeader(400) + } + case r.Method == "PUT" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + recordResponse := map[string]any{ + "id": recordId, + "name": "sub.example.com", + "rootName": "example.com", + "type": "A", + "content": "192.168.1.1", + "ttl": 60, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + record, err := provider.UpdateRecord("example.com", "sub.example.com", net.ParseIP("192.168.1.1"), 60, 0, false) if err != nil { t.Errorf("Expected nil, got %v", err) } - if record.Domain != "sub" { - t.Errorf("Expected sub, got %s", record.Domain) + if record.Domain != "sub.example.com" { + t.Errorf("Expected sub.example.com, got %s", record.Domain) } - if record.IP != "127.0.0.1" { - t.Errorf("Expected 127.0.0.1, got %s", record.IP) + if record.IP != "192.168.1.1" { + t.Errorf("Expected 192.168.1.1, got %s", record.IP) } if record.TTL != 60 { @@ -155,71 +1127,338 @@ func testUpdateRecordAPISuccess() func(*testing.T) { } } -func TestGetRecord(t *testing.T) { - t.Run("API error", testGetRecordAPIError()) - t.Run("API success", testGetRecordAPISuccess()) +func testUpdateRecordSuccessTLD() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "jihgfedcba" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + recordName := r.URL.Query().Get("recordName") + if recordName == "example.com" { + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + } else { + w.WriteHeader(400) + } + case r.Method == "PUT" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + recordResponse := map[string]any{ + "id": recordId, + "name": "example.com", + "rootName": "example.com", + "type": "A", + "content": "192.168.1.1", + "ttl": 300, + "prio": 0, + "disabled": false, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + record, err := provider.UpdateRecord("example.com", "example.com", net.ParseIP("192.168.1.1"), 300, 0, false) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + + if record.Domain != "example.com" { + t.Errorf("Expected example.com, got %s", record.Domain) + } + + if record.IP != "192.168.1.1" { + t.Errorf("Expected 192.168.1.1, got %s", record.IP) + } + } } -func testGetRecordAPIError() func(*testing.T) { +func testUpdateRecordZoneNotFound() func(*testing.T) { return func(t *testing.T) { - api := &MockIonosAPI{ - GetARecordFunc: func(tld string, subdomain string) (*common.ARecord, error) { - return nil, errors.New("API error") - }, + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/zones" { + json.NewEncoder(w).Encode([]map[string]any{}) + } else { + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, } - provider := ionosDnsProvider.IONOS{ - API: api, + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) } - _, err := provider.GetRecord("example.com", "sub") + _, err = provider.UpdateRecord("nonexistent.com", "sub", net.ParseIP("192.168.1.1"), 60, 0, false) if err == nil { t.Error("Expected error, got nil") } } } -func testGetRecordAPISuccess() func(*testing.T) { +func testUpdateRecordRecordNotFound() func(*testing.T) { return func(t *testing.T) { - api := &MockIonosAPI{ - GetARecordFunc: func(tld string, subdomain string) (*common.ARecord, error) { - return &common.ARecord{ - Domain: "sub", - IP: "127.0.0.1", - TTL: 60, - Prio: 0, - Disabled: false, - }, nil - }, + zoneId := "1234567890" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{}, + } + json.NewEncoder(w).Encode(zoneResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, } - provider := ionosDnsProvider.IONOS{ - API: api, - } - - record, err := provider.GetRecord("example.com", "sub") + provider, err := ionosDnsProvider.NewIonos(&config) if err != nil { - t.Errorf("expected nil, got %v", err) + t.Fatalf("Failed to create provider: %v", err) } - if record.Domain != "sub" { - t.Errorf("expected sub, got %s", record.Domain) - } - - if record.IP != "127.0.0.1" { - t.Errorf("expected 127.0.0.1, got %s", record.IP) - } - - if record.TTL != 60 { - t.Errorf("expected 60, got %d", record.TTL) - } - - if record.Prio != 0 { - t.Errorf("expected 0, got %d", record.Prio) - } - - if record.Disabled { - t.Error("expected false, got true") + _, err = provider.UpdateRecord("example.com", "nonexistent", net.ParseIP("192.168.1.1"), 60, 0, false) + if err == nil { + t.Error("Expected error, got nil") + } + } +} + +func testUpdateRecordAPICallFails() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + recordName := r.URL.Query().Get("recordName") + if recordName == "sub.example.com" { + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + } else { + w.WriteHeader(400) + } + case r.Method == "PUT" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + w.WriteHeader(500) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.UpdateRecord("example.com", "sub.example.com", net.ParseIP("192.168.1.1"), 60, 0, false) + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "failed to update record" { + t.Errorf("Expected 'failed to update record', got %v", err) + } + } +} + +func testUpdateRecordEmptyResponse() func(*testing.T) { + return func(t *testing.T) { + zoneId := "1234567890" + recordId := "abcdefghij" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/v1/zones": + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/zones/"+zoneId: + recordName := r.URL.Query().Get("recordName") + if recordName == "sub.example.com" { + zoneResponse := map[string]any{ + "id": zoneId, + "name": "example.com", + "type": "NATIVE", + "records": []map[string]any{ + { + "id": recordId, + "name": "sub.example.com", + "type": "A", + }, + }, + } + json.NewEncoder(w).Encode(zoneResponse) + } else { + w.WriteHeader(400) + } + case r.Method == "PUT" && r.URL.Path == "/v1/zones/"+zoneId+"/records/"+recordId: + // Return record with nil fields + recordResponse := map[string]any{ + "id": recordId, + "name": nil, + "rootName": "example.com", + "type": "A", + "content": nil, + "ttl": nil, + "prio": nil, + "disabled": nil, + } + json.NewEncoder(w).Encode(recordResponse) + default: + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + _, err = provider.UpdateRecord("example.com", "sub.example.com", net.ParseIP("192.168.1.1"), 60, 0, false) + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "record is empty" { + t.Errorf("Expected 'record is empty', got %v", err) + } + } +} + +func TestGetZoneIdCaching(t *testing.T) { + t.Run("Zone ID is cached", testGetZoneIdCaching()) +} + +func testGetZoneIdCaching() func(*testing.T) { + return func(t *testing.T) { + callCount := 0 + zoneId := "1234567890" + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/zones" { + callCount++ + json.NewEncoder(w).Encode([]map[string]any{ + { + "name": "example.com", + "id": zoneId, + "type": "NATIVE", + }, + }) + } else { + w.WriteHeader(404) + } + })) + defer mockServer.Close() + + config := ionosDnsProvider.IONOSConfig{ + APIKey: "test-key", + BaseURL: mockServer.URL, + } + + provider, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + // First call should fetch from API + _, err = provider.GetRecord("example.com", "sub") + if err == nil { + // Second call should use cached zone ID + _, err = provider.GetRecord("example.com", "") + if err == nil { + // The API should only be called once for zones + if callCount > 1 { + t.Errorf("Expected zone API to be called once, but was called %d times", callCount) + } + } } } }