package internal import ( "context" "fmt" "net/http" "net/http/httptest" "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupTest(t *testing.T, subAuthID string, handler http.HandlerFunc) *Client { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) client, err := NewClient("myAuthID", subAuthID, "myAuthPassword") require.NoError(t, err) client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client } func handlerMock(method string, jsonData []byte) http.HandlerFunc { return func(rw http.ResponseWriter, req *http.Request) { if req.Method != method { http.Error(rw, "Incorrect method used", http.StatusBadRequest) return } _, err := rw.Write(jsonData) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } } } func TestNewClient(t *testing.T) { testCases := []struct { desc string authID string subAuthID string authPassword string expected string }{ { desc: "all provided", authID: "1000", subAuthID: "1111", authPassword: "no-secret", }, { desc: "missing authID & subAuthID", authID: "", subAuthID: "", authPassword: "no-secret", expected: "credentials missing: authID or subAuthID", }, { desc: "missing authID & subAuthID", authID: "", subAuthID: "present", authPassword: "", expected: "credentials missing: authPassword", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client, err := NewClient(test.authID, test.subAuthID, test.authPassword) if test.expected != "" { assert.Nil(t, client) require.EqualError(t, err, test.expected) } else { assert.NotNil(t, client) require.NoError(t, err) } }) } } func TestClient_GetZone(t *testing.T) { type expected struct { zone *Zone errorMsg string } testCases := []struct { desc string authFQDN string apiResponse string expected }{ { desc: "zone found", authFQDN: "_acme-challenge.foo.com.", apiResponse: `{"name": "foo.com", "type": "master", "zone": "zone", "status": "1"}`, expected: expected{ zone: &Zone{ Name: "foo.com", Type: "master", Zone: "zone", Status: "1", }, }, }, { desc: "zone not found", authFQDN: "_acme-challenge.foo.com.", apiResponse: ``, expected: expected{ errorMsg: "zone foo.com not found for authFQDN _acme-challenge.foo.com.", }, }, { desc: "invalid json response", authFQDN: "_acme-challenge.foo.com.", apiResponse: `[{}]`, expected: expected{ errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.Zone", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) zone, err := client.GetZone(context.Background(), test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) assert.Equal(t, test.expected.zone, zone) } }) } } func TestClient_FindTxtRecord(t *testing.T) { type expected struct { txtRecord *TXTRecord errorMsg string } testCases := []struct { desc string authFQDN string zoneName string apiResponse string expected }{ { desc: "record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", "host": "_acme-challenge", "record": "txtTXTtxtTXTtxtTXTtxtTXT", "failover": "0", "ttl": "3600", "status": 1 }, "181805209": { "id": "181805209", "type": "TXT", "host": "_github-challenge", "record": "b66b8324b5", "failover": "0", "ttl": "300", "status": 1 } }`, expected: expected{ txtRecord: &TXTRecord{ ID: 5769228, Type: "TXT", Host: "_acme-challenge", Record: "txtTXTtxtTXTtxtTXTtxtTXT", Failover: 0, TTL: 3600, Status: 1, }, }, }, { desc: "no record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", "host": "_other-challenge", "record": "txtTXTtxtTXTtxtTXTtxtTXT", "failover": "0", "ttl": "3600", "status": 1 }, "181805209": { "id": "181805209", "type": "TXT", "host": "_github-challenge", "record": "b66b8324b5", "failover": "0", "ttl": "300", "status": 1 } }`, }, { desc: "zero records", authFQDN: "_acme-challenge.example.com.", zoneName: "example.com", apiResponse: `[]`, }, { desc: "invalid json response", authFQDN: "_acme-challenge.example.com.", zoneName: "example.com", apiResponse: `[{}]`, expected: expected{ errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) txtRecord, err := client.FindTxtRecord(context.Background(), test.zoneName, test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) assert.Equal(t, test.expected.txtRecord, txtRecord) } }) } } func TestClient_ListTxtRecord(t *testing.T) { type expected struct { txtRecords []TXTRecord errorMsg string } testCases := []struct { desc string authFQDN string zoneName string apiResponse string expected }{ { desc: "record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", "host": "_acme-challenge", "record": "txtTXTtxtTXTtxtTXTtxtTXT", "failover": "0", "ttl": "3600", "status": 1 }, "181805209": { "id": "181805209", "type": "TXT", "host": "_github-challenge", "record": "b66b8324b5", "failover": "0", "ttl": "300", "status": 1 } }`, expected: expected{ txtRecords: []TXTRecord{ { ID: 5769228, Type: "TXT", Host: "_acme-challenge", Record: "txtTXTtxtTXTtxtTXTtxtTXT", Failover: 0, TTL: 3600, Status: 1, }, }, }, }, { desc: "no record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", "host": "_other-challenge", "record": "txtTXTtxtTXTtxtTXTtxtTXT", "failover": "0", "ttl": "3600", "status": 1 }, "181805209": { "id": "181805209", "type": "TXT", "host": "_github-challenge", "record": "b66b8324b5", "failover": "0", "ttl": "300", "status": 1 } }`, }, { desc: "zero records", authFQDN: "_acme-challenge.example.com.", zoneName: "example.com", apiResponse: `[]`, }, { desc: "invalid json response", authFQDN: "_acme-challenge.example.com.", zoneName: "example.com", apiResponse: `[{}]`, expected: expected{ errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) txtRecords, err := client.ListTxtRecords(context.Background(), test.zoneName, test.authFQDN) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) assert.Equal(t, test.expected.txtRecords, txtRecords) } }) } } func TestClient_AddTxtRecord(t *testing.T) { type expected struct { query string errorMsg string } testCases := []struct { desc string authID string subAuthID string zoneName string authFQDN string value string ttl int apiResponse string expected }{ { desc: "sub-zone", authID: "myAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.foo.example.com.", value: "txtTXTtxtTXTtxtTXTtxtTXT", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`, }, }, { desc: "main zone (authID)", authID: "myAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`, }, }, { desc: "main zone (subAuthID)", subAuthID: "mySubAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ query: `auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&sub-auth-id=mySubAuthID&ttl=60`, }, }, { desc: "invalid status", authID: "myAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 120, apiResponse: `{"status":"Failed","statusDescription":"Invalid TTL. Choose from the list of the values we support."}`, expected: expected{ query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, errorMsg: "failed to add TXT record: Failed Invalid TTL. Choose from the list of the values we support.", }, }, { desc: "invalid json response", authID: "myAuthID", zoneName: "example.com", authFQDN: "_acme-challenge.example.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 120, apiResponse: `[{}]`, expected: expected{ query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { client := setupTest(t, test.subAuthID, func(rw http.ResponseWriter, req *http.Request) { if test.expected.query != req.URL.RawQuery { msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) http.Error(rw, msg, http.StatusBadRequest) return } handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) }) err := client.AddTxtRecord(context.Background(), test.zoneName, test.authFQDN, test.value, test.ttl) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) } }) } } func TestClient_RemoveTxtRecord(t *testing.T) { type expected struct { query string errorMsg string } testCases := []struct { desc string id int zoneName string apiResponse string expected }{ { desc: "record found", id: 5769228, zoneName: "foo.com", apiResponse: `{ "status": "Success", "statusDescription": "The record was deleted successfully." }`, expected: expected{ query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769228`, }, }, { desc: "record not found", id: 5769000, zoneName: "foo.com", apiResponse: `{ "status": "Failed", "statusDescription": "Invalid record-id param." }`, expected: expected{ query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769000`, errorMsg: "failed to remove TXT record: Failed Invalid record-id param.", }, }, { desc: "invalid json response", id: 44, zoneName: "foo-plus.com", apiResponse: `[{}]`, expected: expected{ query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo-plus.com&record-id=44`, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { if test.expected.query != req.URL.RawQuery { msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) http.Error(rw, msg, http.StatusBadRequest) return } handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) })) t.Cleanup(server.Close) client, err := NewClient("myAuthID", "", "myAuthPassword") require.NoError(t, err) client.BaseURL, _ = url.Parse(server.URL) err = client.RemoveTxtRecord(context.Background(), test.id, test.zoneName) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) } }) } } func TestClient_GetUpdateStatus(t *testing.T) { type expected struct { progress *SyncProgress errorMsg string } testCases := []struct { desc string authFQDN string zoneName string apiResponse string expected }{ { desc: "50% sync", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `[ {"server": "ns101.foo.com.", "ip4": "10.11.12.13", "ip6": "2a00:2a00:2a00:9::5", "updated": true }, {"server": "ns102.foo.com.", "ip4": "10.14.16.17", "ip6": "2100:2100:2100:3::1", "updated": false } ]`, expected: expected{progress: &SyncProgress{Updated: 1, Total: 2}}, }, { desc: "100% sync", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", apiResponse: `[ {"server": "ns101.foo.com.", "ip4": "10.11.12.13", "ip6": "2a00:2a00:2a00:9::5", "updated": true }, {"server": "ns102.foo.com.", "ip4": "10.14.16.17", "ip6": "2100:2100:2100:3::1", "updated": true } ]`, expected: expected{progress: &SyncProgress{Complete: true, Updated: 2, Total: 2}}, }, { desc: "record not found", authFQDN: "_acme-challenge.foo.com.", zoneName: "test-zone", apiResponse: `[]`, expected: expected{errorMsg: "no nameservers records returned"}, }, { desc: "invalid json response", authFQDN: "_acme-challenge.foo.com.", zoneName: "test-zone", apiResponse: `[x]`, expected: expected{errorMsg: "unable to unmarshal response: [status code: 200] body: [x] error: invalid character 'x' looking for beginning of value"}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) t.Cleanup(server.Close) client, err := NewClient("myAuthID", "", "myAuthPassword") require.NoError(t, err) client.BaseURL, _ = url.Parse(server.URL) syncProgress, err := client.GetUpdateStatus(context.Background(), test.zoneName) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) } assert.Equal(t, test.expected.progress, syncProgress) }) } }