diff --git a/internal/provider/selectel/selectel.go b/internal/provider/selectel/selectel.go index 8e52a85..91c4949 100644 --- a/internal/provider/selectel/selectel.go +++ b/internal/provider/selectel/selectel.go @@ -159,10 +159,16 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z } switch d.Kind { case diff.Add: + if d.Desired == nil { + return fmt.Errorf("selectel: add/update diff without Desired record") + } if err := c.do(ctx, http.MethodPost, base, creds.Secret, toRRSet(*d.Desired), nil); err != nil { return err } case diff.Update: + if d.Desired == nil { + return fmt.Errorf("selectel: add/update diff without Desired record") + } id, ok := idByKey[d.Desired.Key()] if !ok { return fmt.Errorf("cannot update: rrset %s not found in zone", d.Desired.Key()) @@ -171,6 +177,9 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z return err } case diff.Delete: + if d.Actual == nil { + return fmt.Errorf("selectel: delete diff without Actual record") + } id, ok := idByKey[d.Actual.Key()] if !ok { return fmt.Errorf("cannot delete: rrset %s not found in zone", d.Actual.Key()) diff --git a/internal/provider/selectel/selectel_test.go b/internal/provider/selectel/selectel_test.go index 529e41a..4b08519 100644 --- a/internal/provider/selectel/selectel_test.go +++ b/internal/provider/selectel/selectel_test.go @@ -266,6 +266,84 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) { } } +// 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: 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) {