diff options
Diffstat (limited to 'internal/porkbun/client_test.go')
| -rw-r--r-- | internal/porkbun/client_test.go | 271 |
1 files changed, 271 insertions, 0 deletions
diff --git a/internal/porkbun/client_test.go b/internal/porkbun/client_test.go new file mode 100644 index 0000000..a33f698 --- /dev/null +++ b/internal/porkbun/client_test.go @@ -0,0 +1,271 @@ +package porkbun + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +const ( + testAPIKey = "pk1_test" + testSecretKey = "sk1_test" +) + +// newTestClient starts an httptest server running handler and returns a +// client pointed at it. +func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return NewClient(testAPIKey, testSecretKey, WithBaseURL(srv.URL)) +} + +// decodeBody decodes the request body and verifies the credentials were sent. +func decodeBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding request body: %v", err) + } + if body["apikey"] != testAPIKey || body["secretapikey"] != testSecretKey { + t.Errorf("credentials not sent: got apikey=%v secretapikey=%v", body["apikey"], body["secretapikey"]) + } + return body +} + +func TestCreateRecord(t *testing.T) { + var gotPath string + var gotBody map[string]any + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + gotBody = decodeBody(t, r) + // Porkbun documents the ID as a string but returns a JSON number. + w.Write([]byte(`{"status":"SUCCESS","id":106926652}`)) + }) + + id, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{ + Name: "www", Type: "A", Content: "192.0.2.1", TTL: 600, Prio: 0, + }) + if err != nil { + t.Fatalf("CreateRecord: %v", err) + } + if id != "106926652" { + t.Errorf("expected id 106926652, got %q", id) + } + if gotPath != "/dns/create/example.com" { + t.Errorf("unexpected path %q", gotPath) + } + for key, want := range map[string]any{ + "name": "www", "type": "A", "content": "192.0.2.1", "ttl": "600", "prio": "0", + } { + if gotBody[key] != want { + t.Errorf("body[%q] = %v, want %v", key, gotBody[key], want) + } + } +} + +func TestCreateRecordStringID(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","id":"42"}`)) + }) + id, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "A", Content: "192.0.2.1", TTL: 600}) + if err != nil { + t.Fatalf("CreateRecord: %v", err) + } + if id != "42" { + t.Errorf("expected id 42, got %q", id) + } +} + +func TestCreateRecordMissingID(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + if _, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "A", Content: "192.0.2.1", TTL: 600}); err == nil { + t.Fatal("expected an error when the API returns no record ID") + } +} + +func TestAPIError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"status":"ERROR","message":"Invalid record type."}`)) + }) + _, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "BOGUS", Content: "x", TTL: 600}) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.HTTPStatus != http.StatusBadRequest || apiErr.Status != "ERROR" || apiErr.Message != "Invalid record type." { + t.Errorf("unexpected APIError: %+v", apiErr) + } +} + +func TestEditRecord(t *testing.T) { + var gotPath string + var gotBody map[string]any + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotBody = decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + + err := client.EditRecord(context.Background(), "example.com", "42", RecordSpec{ + Name: "www", Type: "A", Content: "192.0.2.2", TTL: 700, Prio: 0, + }) + if err != nil { + t.Fatalf("EditRecord: %v", err) + } + if gotPath != "/dns/edit/example.com/42" { + t.Errorf("unexpected path %q", gotPath) + } + // The edit endpoint does not accept a name; it must not be sent. + if _, ok := gotBody["name"]; ok { + t.Errorf("edit request must not include a name field, got %v", gotBody["name"]) + } + if gotBody["content"] != "192.0.2.2" || gotBody["ttl"] != "700" { + t.Errorf("unexpected edit body: %v", gotBody) + } +} + +func TestDeleteRecord(t *testing.T) { + var gotPath string + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + if err := client.DeleteRecord(context.Background(), "example.com", "42"); err != nil { + t.Fatalf("DeleteRecord: %v", err) + } + if gotPath != "/dns/delete/example.com/42" { + t.Errorf("unexpected path %q", gotPath) + } +} + +func TestGetRecord(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/dns/retrieve/example.com/42" { + t.Errorf("unexpected path %q", r.URL.Path) + } + // String-typed numeric fields, as documented. + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":"42","name":"www.example.com","type":"A","content":"192.0.2.1","ttl":"600","prio":"0","notes":""} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + want := Record{ID: "42", Name: "www.example.com", Type: "A", Content: "192.0.2.1", TTL: 600, Prio: 0} + if rec == nil || *rec != want { + t.Errorf("GetRecord = %+v, want %+v", rec, want) + } +} + +func TestGetRecordNumericFields(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":42,"name":"mail.example.com","type":"MX","content":"mx.example.com","ttl":600,"prio":10} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + want := Record{ID: "42", Name: "mail.example.com", Type: "MX", Content: "mx.example.com", TTL: 600, Prio: 10} + if rec == nil || *rec != want { + t.Errorf("GetRecord = %+v, want %+v", rec, want) + } +} + +func TestGetRecordNullPrio(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":"42","name":"example.com","type":"A","content":"192.0.2.1","ttl":"600","prio":null} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + if rec.Prio != 0 { + t.Errorf("expected null prio to parse as 0, got %d", rec.Prio) + } +} + +func TestGetRecordNotFound(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "999") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + if rec != nil { + t.Errorf("expected nil record for missing ID, got %+v", rec) + } +} + +func TestPing(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ping" { + t.Errorf("unexpected path %q", r.URL.Path) + } + decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS","yourIp":"203.0.113.7"}`)) + }) + ip, err := client.Ping(context.Background()) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if ip != "203.0.113.7" { + t.Errorf("expected ip 203.0.113.7, got %q", ip) + } +} + +func TestBaseURLTrailingSlash(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Write([]byte(`{"status":"SUCCESS","yourIp":"1.2.3.4"}`)) + })) + t.Cleanup(srv.Close) + + client := NewClient(testAPIKey, testSecretKey, WithBaseURL(srv.URL+"/")) + if _, err := client.Ping(context.Background()); err != nil { + t.Fatalf("Ping: %v", err) + } + if gotPath != "/ping" { + t.Errorf("trailing slash not normalized: path %q", gotPath) + } +} + +func TestUnparseableResponse(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`<html>bad gateway</html>`)) + }) + if _, err := client.GetRecord(context.Background(), "example.com", "42"); err == nil { + t.Fatal("expected an error for a non-JSON response") + } +} + +func TestPathEscaping(t *testing.T) { + var gotPath string + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + // A malicious "domain" must not be able to change the request path. + _ = client.DeleteRecord(context.Background(), "example.com/../../admin", "42") + if gotPath != "/dns/delete/example.com%2F..%2F..%2Fadmin/42" { + t.Errorf("path traversal not escaped: %q", gotPath) + } +} |