32107571d1
Selectel Cloud DNS v2 requires a project IAM token in X-Auth-Token, not the raw service-user secret; the previous client sent the static secret directly and got 401. The client now parses Credentials.Secret as a Creds JSON blob (username/password/account_id/project_name), exchanges it for a token via the Identity API (POST /identity/v3/auth/tokens), and caches the token in memory per-account until 5 minutes before expiry. ListZones/GetRecords/ ApplyChanges send the cached IAM token instead of the raw secret. provider.Provider gains a Validate(ctx, Credentials) method so a bad account can be rejected via trial login at creation time; all Provider fakes across provider/registry/api/service test packages implement it as a no-op stub for now (Task 2 will make api's mock configurable). Security: the service-user password is folded into the token cache key via SHA-256 (never stored in the clear) so a password change invalidates the cached token; identity errors are generic and never echo the request body. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
642 lines
22 KiB
Go
642 lines
22 KiB
Go
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"
|
|
)
|
|
|
|
// testIdentityToken is the IAM token handed out by identityOKHandler. Tests
|
|
// assert on this value (not the raw secret) to prove the client sends the
|
|
// token obtained from the Identity API, never the service-user password.
|
|
const testIdentityToken = "tok-1"
|
|
|
|
// testCreds returns valid Selectel service-user credentials (the decrypted
|
|
// JSON blob provider.Credentials.Secret carries for this provider).
|
|
func testCreds() provider.Credentials {
|
|
return provider.Credentials{Secret: `{"username":"u","password":"p","account_id":"123","project_name":"proj"}`}
|
|
}
|
|
|
|
// identityOKHandler emulates a healthy Identity API: 201 Created with the
|
|
// token in the X-Subject-Token header and expires_at far in the future.
|
|
func identityOKHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Subject-Token", testIdentityToken)
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"token": map[string]any{"expires_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339)},
|
|
})
|
|
}
|
|
|
|
// newTestClient wires a single httptest.Server that routes the Identity
|
|
// endpoint to identityHandler and everything else (the DNS v2 API) to
|
|
// v2Handler, and returns a Client pointed at it.
|
|
func newTestClient(identityHandler, v2Handler http.HandlerFunc) (*Client, *httptest.Server) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/identity/v3/auth/tokens", identityHandler)
|
|
mux.HandleFunc("/", v2Handler)
|
|
srv := httptest.NewServer(mux)
|
|
c := &Client{
|
|
BaseURL: srv.URL,
|
|
IdentityURL: srv.URL + "/identity/v3/auth/tokens",
|
|
HTTP: srv.Client(),
|
|
tokens: map[string]cachedToken{},
|
|
}
|
|
return c, srv
|
|
}
|
|
|
|
// --- Step 1: Validate performs a trial login against Identity ---
|
|
|
|
func TestValidateSuccess(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/identity/v3/auth/tokens" {
|
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
}
|
|
identityOKHandler(w, r)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}}
|
|
if err := c.Validate(context.Background(), testCreds()); err != nil {
|
|
t.Fatalf("Validate: unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateIdentityUnauthorizedReturnsError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}}
|
|
if err := c.Validate(context.Background(), testCreds()); err == nil {
|
|
t.Fatal("expected error when identity rejects credentials with 401")
|
|
}
|
|
}
|
|
|
|
func TestValidateInvalidCredentialsFormatReturnsErrorWithoutCallingIdentity(t *testing.T) {
|
|
called := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}}
|
|
err := c.Validate(context.Background(), provider.Credentials{Secret: `{"username":"u"}`})
|
|
if err == nil {
|
|
t.Fatal("expected error for incomplete credentials")
|
|
}
|
|
if called {
|
|
t.Fatal("identity must not be called for malformed/incomplete credentials")
|
|
}
|
|
}
|
|
|
|
// --- Step 6: token cache reuse / refresh, and ListZones sends the IAM token ---
|
|
|
|
func TestTokenCachedAcrossCalls(t *testing.T) {
|
|
var identityHits int
|
|
c, srv := newTestClient(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
identityHits++
|
|
identityOKHandler(w, r)
|
|
},
|
|
func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) },
|
|
)
|
|
defer srv.Close()
|
|
|
|
cr, err := parseCreds(testCreds().Secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.token(context.Background(), cr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.token(context.Background(), cr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if identityHits != 1 {
|
|
t.Fatalf("expected a single Identity call thanks to caching, got %d", identityHits)
|
|
}
|
|
}
|
|
|
|
func TestTokenRefreshesWhenExpiring(t *testing.T) {
|
|
var identityHits int
|
|
c, srv := newTestClient(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
identityHits++
|
|
w.Header().Set("X-Subject-Token", testIdentityToken)
|
|
w.WriteHeader(http.StatusCreated)
|
|
// expires_at already inside the leeway window -> every token() call
|
|
// must re-authenticate instead of serving a near-expiry cache hit.
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"token": map[string]any{"expires_at": time.Now().Add(-time.Minute).Format(time.RFC3339)},
|
|
})
|
|
},
|
|
func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) },
|
|
)
|
|
defer srv.Close()
|
|
|
|
cr, err := parseCreds(testCreds().Secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.token(context.Background(), cr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.token(context.Background(), cr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if identityHits != 2 {
|
|
t.Fatalf("expected re-authentication on every call while token is within leeway of expiry, got %d hits", identityHits)
|
|
}
|
|
}
|
|
|
|
func TestListZonesSendsIAMTokenNotRawSecret(t *testing.T) {
|
|
var gotToken string
|
|
c, srv := newTestClient(identityOKHandler, 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{}, "next_offset": 0})
|
|
})
|
|
defer srv.Close()
|
|
|
|
if _, err := c.ListZones(context.Background(), testCreds()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotToken != testIdentityToken {
|
|
t.Fatalf("expected IAM token %q sent as X-Auth-Token, got %q", testIdentityToken, gotToken)
|
|
}
|
|
}
|
|
|
|
// --- existing behavior, now exercised through the Identity + v2 flow ---
|
|
|
|
func TestListZonesSendsTokenAndParses(t *testing.T) {
|
|
var gotToken string
|
|
c, srv := newTestClient(identityOKHandler, 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(), testCreds())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotToken != testIdentityToken {
|
|
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(identityOKHandler, 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(), testCreds(), "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(identityOKHandler, 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(), testCreds(), "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(identityOKHandler, 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(), testCreds(), "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(identityOKHandler, 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(), testCreds(), "z1", cs); err == nil {
|
|
t.Fatal("expected non-nil error when delete rrset id is not found")
|
|
}
|
|
}
|
|
|
|
// Global Constraint: X-Auth-Token (the IAM token, not the raw secret) must be
|
|
// sent on mutating requests (POST/PATCH/DELETE), not only on GET.
|
|
func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
|
|
var tokens []string
|
|
c, srv := newTestClient(identityOKHandler, 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(), testCreds(), "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 != testIdentityToken {
|
|
t.Fatalf("expected X-Auth-Token %q on mutation, got %q", testIdentityToken, 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(identityOKHandler, 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(), testCreds())
|
|
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(identityOKHandler, 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(), testCreds(), "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(identityOKHandler, 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(), testCreds(), "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, identity URL,
|
|
// a 30s HTTP timeout, and an initialized token cache.
|
|
func TestNewDefaults(t *testing.T) {
|
|
c := New()
|
|
if c.BaseURL != DefaultBaseURL {
|
|
t.Fatalf("BaseURL = %q, want %q", c.BaseURL, DefaultBaseURL)
|
|
}
|
|
if c.IdentityURL != DefaultIdentityURL {
|
|
t.Fatalf("IdentityURL = %q, want %q", c.IdentityURL, DefaultIdentityURL)
|
|
}
|
|
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)
|
|
}
|
|
if c.tokens == nil {
|
|
t.Fatal("token cache map must be initialized")
|
|
}
|
|
}
|
|
|
|
// 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(identityOKHandler, 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(), testCreds())
|
|
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(identityOKHandler, 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(), testCreds(), "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(identityOKHandler, 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(), testCreds())
|
|
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)
|
|
}
|
|
}
|