aboutsummaryrefslogtreecommitdiff
path: root/internal/porkbun/client_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/porkbun/client_test.go')
-rw-r--r--internal/porkbun/client_test.go271
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)
+ }
+}