From 9b38f081f476f266b687a3a25b13599931e1d4b5 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 12:48:59 +0700 Subject: [PATCH] =?UTF-8?q?test(selectel):=20=D0=BF=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B5=20id-not-found,=20=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=20=D0=BC=D1=83=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=D1=85,=20=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20HTTP-=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/selectel/selectel_test.go | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/internal/provider/selectel/selectel_test.go b/internal/provider/selectel/selectel_test.go index a50106b..529e41a 100644 --- a/internal/provider/selectel/selectel_test.go +++ b/internal/provider/selectel/selectel_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/vasyakrg/dns-autoresolver/internal/diff" @@ -130,3 +131,165 @@ func TestApplyChangesRoutesVerbs(t *testing.T) { } } } + +// 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: 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) + } +}