package selectel import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/provider" ) func creds() provider.Credentials { return provider.Credentials{Secret: "secret-token"} } func newTestClient(h http.Handler) (*Client, *httptest.Server) { srv := httptest.NewServer(h) return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv } func TestListZonesSendsTokenAndParses(t *testing.T) { var gotToken string c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotToken = r.Header.Get("X-Auth-Token") json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "z1", "name": "example.com."}, {"id": "z2", "name": "test.org."}, }, "next_offset": 0, }) })) defer srv.Close() zs, err := c.ListZones(context.Background(), creds()) if err != nil { t.Fatal(err) } if gotToken != "secret-token" { t.Fatalf("token not sent, got %q", gotToken) } if len(zs) != 2 || zs[0].ID != "z1" || zs[1].Name != "test.org." { t.Fatalf("unexpected zones: %+v", zs) } } func TestGetRecordsMapsRRSet(t *testing.T) { c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "r1", "name": "example.com.", "type": "MX", "ttl": 3600, "records": []map[string]any{{"content": "10 mx1.example.com.", "disabled": false}}}, {"id": "r2", "name": "www.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "1.2.3.4"}, {"content": "5.6.7.8", "disabled": true}}}, }, "next_offset": 0, }) })) defer srv.Close() recs, err := c.GetRecords(context.Background(), creds(), "z1") if err != nil { t.Fatal(err) } if len(recs) != 2 { t.Fatalf("want 2 records, got %d", len(recs)) } var a model.Record for _, r := range recs { if r.Type == model.A { a = r } } // disabled record dropped -> only one value if len(a.Values) != 1 || a.Values[0] != "1.2.3.4" { t.Fatalf("disabled record must be skipped, got %+v", a.Values) } } func TestApplyChangesRoutesVerbs(t *testing.T) { type call struct{ method, path string } var calls []call c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // GET rrset -> return existing set with ids for update/delete resolution if r.Method == http.MethodGet { json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "up1", "name": "b.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "9.9.9.9"}}}, {"id": "del1", "name": "d.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "4.4.4.4"}}}, }, "next_offset": 0, }) return } calls = append(calls, call{r.Method, r.URL.Path}) w.WriteHeader(http.StatusOK) })) defer srv.Close() add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}} updDesired := model.Record{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}} delActual := model.Record{Type: model.A, Name: "d.example.com.", TTL: 300, Values: []string{"4.4.4.4"}} ns := model.Record{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}} cs := diff.Changeset{Diffs: []diff.RecordDiff{ {Kind: diff.Add, Type: add.Type, Name: add.Name, Desired: &add}, {Kind: diff.Update, Type: updDesired.Type, Name: updDesired.Name, Desired: &updDesired}, {Kind: diff.Delete, Type: delActual.Type, Name: delActual.Name, Actual: &delActual}, {Kind: diff.Update, Type: ns.Type, Name: ns.Name, Desired: &ns, ReadOnly: true}, // must be skipped }} if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil { t.Fatal(err) } want := map[string]bool{ "POST /zones/z1/rrset": true, "PATCH /zones/z1/rrset/up1": true, "DELETE /zones/z1/rrset/del1": true, } if len(calls) != len(want) { t.Fatalf("want %d calls, got %v", len(want), calls) } for _, cl := range calls { if !want[cl.method+" "+cl.path] { t.Fatalf("unexpected call %s %s", cl.method, cl.path) } } } // Global Constraint: id not found for Update -> error, and mutation must not proceed. func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T) { var calls []string c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { // empty existing rrset set -> nothing resolves to an id json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0}) return } calls = append(calls, r.Method+" "+r.URL.Path) w.WriteHeader(http.StatusOK) })) defer srv.Close() missing := model.Record{Type: model.A, Name: "missing.example.com.", TTL: 300, Values: []string{"1.1.1.1"}} add := model.Record{Type: model.A, Name: "new.example.com.", TTL: 300, Values: []string{"2.2.2.2"}} cs := diff.Changeset{Diffs: []diff.RecordDiff{ {Kind: diff.Update, Type: missing.Type, Name: missing.Name, Desired: &missing}, {Kind: diff.Add, Type: add.Type, Name: add.Name, Desired: &add}, }} err := c.ApplyChanges(context.Background(), creds(), "z1", cs) if err == nil { t.Fatal("expected non-nil error when update rrset id is not found") } if len(calls) != 0 { t.Fatalf("expected no mutating requests to be sent, got %v", calls) } } // Global Constraint: id not found for Delete -> error. func TestApplyChangesDeleteIDNotFoundReturnsError(t *testing.T) { c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0}) return } t.Fatalf("unexpected mutating call %s %s, delete should have errored before reaching HTTP", r.Method, r.URL.Path) })) defer srv.Close() missing := model.Record{Type: model.A, Name: "missing.example.com.", TTL: 300, Values: []string{"1.1.1.1"}} cs := diff.Changeset{Diffs: []diff.RecordDiff{ {Kind: diff.Delete, Type: missing.Type, Name: missing.Name, Actual: &missing}, }} if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err == nil { t.Fatal("expected non-nil error when delete rrset id is not found") } } // Global Constraint: X-Auth-Token must be sent on mutating requests (POST/PATCH/DELETE), not only on GET. func TestApplyChangesSendsTokenOnMutations(t *testing.T) { var tokens []string c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "up1", "name": "b.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "9.9.9.9"}}}, {"id": "del1", "name": "d.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "4.4.4.4"}}}, }, "next_offset": 0, }) return } tokens = append(tokens, r.Header.Get("X-Auth-Token")) w.WriteHeader(http.StatusOK) })) defer srv.Close() add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}} updDesired := model.Record{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}} delActual := model.Record{Type: model.A, Name: "d.example.com.", TTL: 300, Values: []string{"4.4.4.4"}} cs := diff.Changeset{Diffs: []diff.RecordDiff{ {Kind: diff.Add, Type: add.Type, Name: add.Name, Desired: &add}, {Kind: diff.Update, Type: updDesired.Type, Name: updDesired.Name, Desired: &updDesired}, {Kind: diff.Delete, Type: delActual.Type, Name: delActual.Name, Actual: &delActual}, }} if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil { t.Fatal(err) } if len(tokens) != 3 { t.Fatalf("expected 3 mutating requests (POST/PATCH/DELETE), got %d", len(tokens)) } for _, tok := range tokens { if tok != "secret-token" { t.Fatalf("expected X-Auth-Token %q on mutation, got %q", "secret-token", tok) } } } // Global Constraint: multi-page pagination must accumulate records across pages without an infinite loop. func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) { var offsets []string c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") offsets = append(offsets, offset) if len(offsets) > 2 { t.Fatalf("too many requests, possible infinite pagination loop: %v", offsets) } switch offset { case "0": json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{{"id": "z1", "name": "first.example.com."}}, "next_offset": 1000, }) case "1000": json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{{"id": "z2", "name": "second.example.com."}}, "next_offset": 0, }) default: t.Fatalf("unexpected offset %q", offset) } })) defer srv.Close() zs, err := c.ListZones(context.Background(), creds()) if err != nil { t.Fatal(err) } if len(offsets) != 2 { t.Fatalf("expected exactly 2 page requests, got %d: %v", len(offsets), offsets) } if len(zs) != 2 || zs[0].ID != "z1" || zs[1].ID != "z2" { t.Fatalf("expected accumulated zones from both pages, got %+v", zs) } } // Global Constraint: listRRSets (via GetRecords) must paginate across multiple pages, // accumulating records from every page, and must stop as soon as next_offset is 0 — // no third request should ever be issued. func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) { var offsets []string c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") offsets = append(offsets, offset) if len(offsets) > 2 { t.Fatalf("too many requests, possible infinite pagination loop: %v", offsets) } switch offset { case "0": json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "r1", "name": "a.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "1.1.1.1"}}}, }, "next_offset": 1000, }) case "1000": json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "r2", "name": "b.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "2.2.2.2"}}}, }, "next_offset": 0, }) default: t.Fatalf("unexpected offset %q", offset) } })) defer srv.Close() recs, err := c.GetRecords(context.Background(), creds(), "z1") if err != nil { t.Fatal(err) } if len(offsets) != 2 { t.Fatalf("expected exactly 2 page requests, got %d: %v", len(offsets), offsets) } if len(recs) != 2 { t.Fatalf("expected accumulated records from both pages, got %+v", recs) } names := map[string]bool{recs[0].Name: true, recs[1].Name: true} if !names["a.example.com."] || !names["b.example.com."] { t.Fatalf("expected records from both pages, got %+v", recs) } } // Global Constraint: ApplyChanges must not panic on a Changeset with a nil Desired // record for Add/Update, and must instead return a clear error. func TestApplyChangesAddWithNilDesiredReturnsErrorNoPanic(t *testing.T) { c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0}) return } t.Fatalf("unexpected mutating call %s %s, nil Desired should have errored before reaching HTTP", r.Method, r.URL.Path) })) defer srv.Close() cs := diff.Changeset{Diffs: []diff.RecordDiff{ {Kind: diff.Add, Type: model.A, Name: "nil-desired.example.com.", Desired: nil}, }} defer func() { if r := recover(); r != nil { t.Fatalf("ApplyChanges panicked on nil Desired: %v", r) } }() err := c.ApplyChanges(context.Background(), creds(), "z1", cs) if err == nil { t.Fatal("expected non-nil error for Add diff with nil Desired") } } // Global Constraint: New() must configure the default base URL and a 30s HTTP timeout. func TestNewDefaults(t *testing.T) { c := New() if c.BaseURL != DefaultBaseURL { t.Fatalf("BaseURL = %q, want %q", c.BaseURL, DefaultBaseURL) } if c.HTTP == nil { t.Fatal("HTTP client must not be nil") } if c.HTTP.Timeout != 30*time.Second { t.Fatalf("HTTP.Timeout = %v, want %v", c.HTTP.Timeout, 30*time.Second) } } // Global Constraint: if the API returns a NextOffset that does not advance past the // current offset (stuck or regressing), even with a non-empty page, ListZones must // stop pagination instead of looping forever on the same/earlier offset. func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) { var requests int c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ if requests > 5 { t.Fatalf("too many requests, possible infinite pagination loop on stuck offset") } offset := r.URL.Query().Get("offset") switch offset { case "0": json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{{"id": "z1", "name": "first.example.com."}}, "next_offset": 1000, }) case "1000": // Buggy/malicious API: next_offset does not advance past current offset, // yet the page is non-empty. json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{{"id": "z2", "name": "second.example.com."}}, "next_offset": 1000, }) default: t.Fatalf("unexpected offset %q", offset) } })) defer srv.Close() zs, err := c.ListZones(context.Background(), creds()) if err != nil { t.Fatal(err) } if requests != 2 { t.Fatalf("expected exactly 2 requests before stopping on stuck offset, got %d", requests) } if len(zs) != 2 || zs[0].ID != "z1" || zs[1].ID != "z2" { t.Fatalf("expected zones accumulated from both pages before stopping, got %+v", zs) } } // Global Constraint: same guard applies to listRRSets (exercised via GetRecords) — // a non-advancing NextOffset on a non-empty page must stop pagination, not loop forever. func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) { var requests int c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ if requests > 5 { t.Fatalf("too many requests, possible infinite pagination loop on stuck offset") } offset := r.URL.Query().Get("offset") switch offset { case "0": json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "r1", "name": "a.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "1.1.1.1"}}}, }, "next_offset": 1000, }) case "1000": // Buggy/malicious API: next_offset regresses below current offset, // yet the page is non-empty. json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "r2", "name": "b.example.com.", "type": "A", "ttl": 300, "records": []map[string]any{{"content": "2.2.2.2"}}}, }, "next_offset": 500, }) default: t.Fatalf("unexpected offset %q", offset) } })) defer srv.Close() recs, err := c.GetRecords(context.Background(), creds(), "z1") if err != nil { t.Fatal(err) } if requests != 2 { t.Fatalf("expected exactly 2 requests before stopping on stuck/regressing offset, got %d", requests) } if len(recs) != 2 { t.Fatalf("expected records accumulated from both pages before stopping, got %+v", recs) } } // Global Constraint: HTTP errors (status >= 300) must surface a non-nil error whose text // includes the method/path/status (or response body) for diagnosability. func TestListZonesHTTPErrorIncludesMethodPathStatus(t *testing.T) { c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte("zone not found")) })) defer srv.Close() _, err := c.ListZones(context.Background(), creds()) if err == nil { t.Fatal("expected non-nil error on non-2xx response") } msg := err.Error() if !strings.Contains(msg, http.MethodGet) { t.Fatalf("error should mention HTTP method %q, got %q", http.MethodGet, msg) } if !strings.Contains(msg, "404") { t.Fatalf("error should mention status code 404, got %q", msg) } if !strings.Contains(msg, "/zones") { t.Fatalf("error should mention request path, got %q", msg) } if !strings.Contains(msg, "zone not found") { t.Fatalf("error should include response body, got %q", msg) } }