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(`bad gateway`)) }) 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) } }