package hetzner import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" ) func TestDNSWritesWaitForActionCompletion(t *testing.T) { operations := map[string]func(*Client) error{ "create": func(c *Client) error { return c.CreateRRSet(context.Background(), "token", "123", "home", "A", 60, nil) }, "records": func(c *Client) error { return c.SetRRSetRecords(context.Background(), "token", "123", "home", "A", nil) }, "ttl": func(c *Client) error { return c.ChangeRRSetTTL(context.Background(), "token", "123", "home", "A", 60) }, "comments": func(c *Client) error { return c.UpdateRecordComments(context.Background(), "token", "123", "home", "A", nil) }, "delete": func(c *Client) error { return c.DeleteRRSet(context.Background(), "token", "123", "home", "A") }, } for name, operation := range operations { t.Run(name, func(t *testing.T) { for _, status := range []string{"success", "error"} { t.Run(status, func(t *testing.T) { var writes, polls int api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writes++ _, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`)) return } if r.URL.Path != "/zones/actions/42" || r.Header.Get("Authorization") != "Bearer token" { t.Errorf("incorrect action polling request: %s", r.URL.Path) } polls++ if polls == 1 { _, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`)) } else { _, _ = w.Write([]byte(`{"action":{"id":42,"status":"` + status + `","error":{"code":"action_failed","message":"failed later"}}}`)) } })) defer api.Close() client := New(api.URL) client.actionPollInterval = time.Millisecond err := operation(client) if status == "success" && err != nil { t.Fatal(err) } var actionErr *ActionError if status == "error" && (!errors.As(err, &actionErr) || actionErr.Code != "action_failed") { t.Fatalf("lost action failure: %v", err) } if status == "error" && (diagnostics.Failure(err) != "provider_action" || diagnostics.ActionID(err) != 42) { t.Fatal("action failure missing from safe diagnostics") } if writes != 1 || polls != 2 { t.Fatalf("returned too early or repeated write: writes=%d polls=%d", writes, polls) } }) } }) } } func TestActionWaitIsBoundedAndRejectsMalformedResponses(t *testing.T) { for _, body := range []string{"", `{}`, `{"action":{"id":42,"status":"unknown"}}`, `{"action":{"status":"running"}}`, `{"action":{"id":42,"status":"running"}}`} { t.Run(body, func(t *testing.T) { api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) })) defer api.Close() client := New(api.URL) client.actionPollInterval = time.Millisecond client.actionTimeout = 30 * time.Millisecond if err := client.DeleteRRSet(context.Background(), "token", "123", "home", "A"); err == nil { t.Fatal("unconfirmed operation reported success") } ctx, cancel := context.WithCancel(context.Background()) cancel() if err := client.DeleteRRSet(ctx, "token", "123", "home", "A"); !errors.Is(err, context.Canceled) { t.Fatalf("cancellation lost: %v", err) } }) } } func TestPaginationRejectsRepeatedPage(t *testing.T) { api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"zones":[],"rrsets":[],"meta":{"pagination":{"next_page":1}}}`)) })) defer api.Close() client := New(api.URL) if _, err := client.ListZones(context.Background(), "token"); err == nil { t.Fatal("repeated zones page accepted") } if _, err := client.ListRRSets(context.Background(), "token", "123"); err == nil { t.Fatal("repeated RRsets page accepted") } } func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) { t.Parallel() type capturedRequest struct { Method string Path string Authorization string Records []Record } captured := make(chan capturedRequest, 1) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Records []Record `json:"records"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } captured <- capturedRequest{ Method: r.Method, Path: r.URL.Path, Authorization: r.Header.Get("Authorization"), Records: body.Records, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) })) defer api.Close() client := New(api.URL) err := client.SetRRSetRecords( context.Background(), "test-token", "123", "home", "A", []Record{{Value: "198.51.100.42", Comment: "DynDNS"}}, ) if err != nil { t.Fatalf("set RRSet records: %v", err) } request := <-captured if request.Method != http.MethodPost { t.Errorf("unexpected method: got %s, want POST", request.Method) } if request.Path != "/zones/123/rrsets/home/A/actions/set_records" { t.Errorf("unexpected path: %s", request.Path) } if request.Authorization != "Bearer test-token" { t.Errorf("unexpected authorization header: %q", request.Authorization) } if len(request.Records) != 1 || request.Records[0].Value != "198.51.100.42" || request.Records[0].Comment != "DynDNS" { t.Errorf("unexpected records payload: %#v", request.Records) } } func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) { t.Parallel() captured := make(chan map[string]any, 1) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } body["method"] = r.Method body["path"] = r.URL.Path captured <- body w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) })) defer api.Close() client := New(api.URL) if err := client.UpdateRecordComments(context.Background(), "test-token", "123", "home", "A", []Record{{Value: "198.51.100.42"}}); err != nil { t.Fatalf("update record comment: %v", err) } request := <-captured if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/update_records" { t.Fatalf("unexpected request: %#v", request) } records, ok := request["records"].([]any) if !ok || len(records) != 1 { t.Fatalf("unexpected records payload: %#v", request["records"]) } record, ok := records[0].(map[string]any) if !ok { t.Fatalf("unexpected record payload: %#v", records[0]) } comment, exists := record["comment"] if !exists || comment != "" { t.Errorf("empty comment must be sent explicitly, got %#v", record) } } func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) { t.Parallel() captured := make(chan map[string]any, 1) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } body["method"] = r.Method body["path"] = r.URL.Path captured <- body w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) })) defer api.Close() client := New(api.URL) if err := client.ChangeRRSetTTL(context.Background(), "test-token", "123", "home", "A", 300); err != nil { t.Fatalf("change RRSet TTL: %v", err) } request := <-captured if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/change_ttl" || request["ttl"] != float64(300) { t.Fatalf("unexpected request: %#v", request) } } func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) { t.Parallel() captured := make(chan *http.Request, 1) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { captured <- r w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) })) defer api.Close() client := New(api.URL) if err := client.DeleteRRSet(context.Background(), "test-token", "123", "www", "CNAME"); err != nil { t.Fatalf("delete RRSet: %v", err) } request := <-captured if request.Method != http.MethodDelete || request.URL.Path != "/zones/123/rrsets/www/CNAME" || request.Header.Get("Authorization") != "Bearer test-token" { t.Fatalf("unexpected request: %s %s %q", request.Method, request.URL.Path, request.Header.Get("Authorization")) } }