feat(selectel): project-scoped IAM auth with token cache; provider Validate
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
This commit is contained in:
@@ -3,11 +3,14 @@ package selectel
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||
@@ -15,20 +18,166 @@ import (
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||
)
|
||||
|
||||
const DefaultBaseURL = "https://api.selectel.ru/domains/v2"
|
||||
const (
|
||||
DefaultBaseURL = "https://api.selectel.ru/domains/v2"
|
||||
DefaultIdentityURL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
|
||||
tokenLeeway = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Client implements provider.Provider for Selectel DNS API v2.
|
||||
// Client implements provider.Provider for Selectel DNS API v2. Credentials
|
||||
// are a service-user login (see Creds); the client exchanges them for a
|
||||
// project-scoped IAM token via the Identity API and caches it in memory.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
BaseURL string
|
||||
IdentityURL string
|
||||
HTTP *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
tokens map[string]cachedToken
|
||||
}
|
||||
|
||||
// cachedToken is an in-memory IAM token with its expiry, keyed by cacheKey.
|
||||
type cachedToken struct {
|
||||
token string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// Creds is the Selectel service-user credential set, stored as the encrypted
|
||||
// JSON blob in provider_accounts.secret_enc and parsed from Credentials.Secret.
|
||||
type Creds struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
AccountID string `json:"account_id"`
|
||||
ProjectName string `json:"project_name"`
|
||||
}
|
||||
|
||||
func parseCreds(secret string) (Creds, error) {
|
||||
var c Creds
|
||||
if err := json.Unmarshal([]byte(secret), &c); err != nil {
|
||||
return Creds{}, fmt.Errorf("selectel: invalid credentials format")
|
||||
}
|
||||
if c.Username == "" || c.Password == "" || c.AccountID == "" || c.ProjectName == "" {
|
||||
return Creds{}, fmt.Errorf("selectel: username, password, account_id and project_name are required")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{BaseURL: DefaultBaseURL, HTTP: &http.Client{Timeout: 30 * time.Second}}
|
||||
return &Client{
|
||||
BaseURL: DefaultBaseURL,
|
||||
IdentityURL: DefaultIdentityURL,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
tokens: map[string]cachedToken{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Name() string { return "selectel" }
|
||||
|
||||
// cacheKey identifies a service-user identity for the in-memory token cache.
|
||||
// The password is folded in via hash (never in the clear) so that a password
|
||||
// change invalidates any previously cached token for the same account/user.
|
||||
func cacheKey(c Creds) string {
|
||||
sum := sha256.Sum256([]byte(c.AccountID + "\x00" + c.ProjectName + "\x00" + c.Username + "\x00" + c.Password))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// authenticate exchanges the service-user credentials for a project-scoped
|
||||
// IAM token via the Identity API (Keystone v3). SECURITY: the request body
|
||||
// contains the password — errors must never wrap or echo it back.
|
||||
func (c *Client) authenticate(ctx context.Context, cr Creds) (string, time.Time, error) {
|
||||
body := map[string]any{
|
||||
"auth": map[string]any{
|
||||
"identity": map[string]any{
|
||||
"methods": []string{"password"},
|
||||
"password": map[string]any{
|
||||
"user": map[string]any{
|
||||
"name": cr.Username,
|
||||
"domain": map[string]any{"name": cr.AccountID},
|
||||
"password": cr.Password,
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": map[string]any{
|
||||
"project": map[string]any{
|
||||
"name": cr.ProjectName,
|
||||
"domain": map[string]any{"name": cr.AccountID},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.IdentityURL, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
// Never wrap the error with request/body (contains password).
|
||||
return "", time.Time{}, fmt.Errorf("selectel: identity request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return "", time.Time{}, fmt.Errorf("selectel: identity auth failed: %d", resp.StatusCode)
|
||||
}
|
||||
tok := resp.Header.Get("X-Subject-Token")
|
||||
if tok == "" {
|
||||
return "", time.Time{}, fmt.Errorf("selectel: identity returned no token")
|
||||
}
|
||||
var out struct {
|
||||
Token struct {
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
} `json:"token"`
|
||||
}
|
||||
// Body may be absent/short — a decode error is non-fatal; fall back to a
|
||||
// conservative 1h TTL so the token is still usable and refreshed sooner.
|
||||
exp := time.Time{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err == nil && out.Token.ExpiresAt != "" {
|
||||
if t, perr := time.Parse(time.RFC3339, out.Token.ExpiresAt); perr == nil {
|
||||
exp = t
|
||||
}
|
||||
}
|
||||
return tok, exp, nil
|
||||
}
|
||||
|
||||
// token returns a cached, still-valid IAM token for cr, re-authenticating
|
||||
// when there is no cached entry or it is within tokenLeeway of expiring.
|
||||
func (c *Client) token(ctx context.Context, cr Creds) (string, error) {
|
||||
key := cacheKey(cr)
|
||||
c.mu.Lock()
|
||||
if ct, ok := c.tokens[key]; ok && (ct.expires.IsZero() || time.Now().Add(tokenLeeway).Before(ct.expires)) {
|
||||
c.mu.Unlock()
|
||||
return ct.token, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
tok, exp, err := c.authenticate(ctx, cr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Zero expiry (unparseable) → treat as short-lived: cache for 1h from now.
|
||||
if exp.IsZero() {
|
||||
exp = time.Now().Add(time.Hour)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.tokens[key] = cachedToken{token: tok, expires: exp}
|
||||
c.mu.Unlock()
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Validate performs a trial login so bad credentials are rejected at account
|
||||
// creation time rather than at first import.
|
||||
func (c *Client) Validate(ctx context.Context, creds provider.Credentials) error {
|
||||
cr, err := parseCreds(creds.Secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.token(ctx, cr); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- wire types ---
|
||||
|
||||
type apiZone struct {
|
||||
@@ -92,12 +241,20 @@ func (c *Client) do(ctx context.Context, method, path, token string, body any, o
|
||||
// --- Provider implementation ---
|
||||
|
||||
func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]provider.Zone, error) {
|
||||
cr, err := parseCreds(creds.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := c.token(ctx, cr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var zones []provider.Zone
|
||||
offset := 0
|
||||
for {
|
||||
var page apiZoneList
|
||||
path := fmt.Sprintf("/zones?limit=1000&offset=%d", offset)
|
||||
if err := c.do(ctx, http.MethodGet, path, creds.Secret, nil, &page); err != nil {
|
||||
if err := c.do(ctx, http.MethodGet, path, tok, nil, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, z := range page.Result {
|
||||
@@ -116,7 +273,15 @@ func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]p
|
||||
}
|
||||
|
||||
func (c *Client) GetRecords(ctx context.Context, creds provider.Credentials, zoneID string) ([]model.Record, error) {
|
||||
rrsets, err := c.listRRSets(ctx, creds.Secret, zoneID)
|
||||
cr, err := parseCreds(creds.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := c.token(ctx, cr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rrsets, err := c.listRRSets(ctx, tok, zoneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -150,8 +315,16 @@ func (c *Client) listRRSets(ctx context.Context, token, zoneID string) ([]apiRRS
|
||||
}
|
||||
|
||||
func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, zoneID string, cs diff.Changeset) error {
|
||||
cr, err := parseCreds(creds.Secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok, err := c.token(ctx, cr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// resolve rrset ids for update/delete
|
||||
existing, err := c.listRRSets(ctx, creds.Secret, zoneID)
|
||||
existing, err := c.listRRSets(ctx, tok, zoneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,7 +343,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
|
||||
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 {
|
||||
if err := c.do(ctx, http.MethodPost, base, tok, toRRSet(*d.Desired), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
case diff.Update:
|
||||
@@ -181,7 +354,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot update: rrset %s not found in zone", d.Desired.Key())
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPatch, base+"/"+url.PathEscape(id), creds.Secret, toRRSet(*d.Desired), nil); err != nil {
|
||||
if err := c.do(ctx, http.MethodPatch, base+"/"+url.PathEscape(id), tok, toRRSet(*d.Desired), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
case diff.Delete:
|
||||
@@ -192,7 +365,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot delete: rrset %s not found in zone", d.Actual.Key())
|
||||
}
|
||||
if err := c.do(ctx, http.MethodDelete, base+"/"+url.PathEscape(id), creds.Secret, nil, nil); err != nil {
|
||||
if err := c.do(ctx, http.MethodDelete, base+"/"+url.PathEscape(id), tok, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,172 @@ import (
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||
)
|
||||
|
||||
func creds() provider.Credentials { return provider.Credentials{Secret: "secret-token"} }
|
||||
// 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"
|
||||
|
||||
func newTestClient(h http.Handler) (*Client, *httptest.Server) {
|
||||
srv := httptest.NewServer(h)
|
||||
return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv
|
||||
// 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(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
@@ -32,14 +188,14 @@ func TestListZonesSendsTokenAndParses(t *testing.T) {
|
||||
},
|
||||
"next_offset": 0,
|
||||
})
|
||||
}))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
zs, err := c.ListZones(context.Background(), creds())
|
||||
zs, err := c.ListZones(context.Background(), testCreds())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotToken != "secret-token" {
|
||||
if gotToken != testIdentityToken {
|
||||
t.Fatalf("token not sent, got %q", gotToken)
|
||||
}
|
||||
if len(zs) != 2 || zs[0].ID != "z1" || zs[1].Name != "test.org." {
|
||||
@@ -48,7 +204,7 @@ func TestListZonesSendsTokenAndParses(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetRecordsMapsRRSet(t *testing.T) {
|
||||
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
@@ -58,10 +214,10 @@ func TestGetRecordsMapsRRSet(t *testing.T) {
|
||||
},
|
||||
"next_offset": 0,
|
||||
})
|
||||
}))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
recs, err := c.GetRecords(context.Background(), creds(), "z1")
|
||||
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -83,7 +239,7 @@ func TestGetRecordsMapsRRSet(t *testing.T) {
|
||||
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) {
|
||||
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{
|
||||
@@ -99,7 +255,7 @@ func TestApplyChangesRoutesVerbs(t *testing.T) {
|
||||
}
|
||||
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"}}
|
||||
@@ -114,7 +270,7 @@ func TestApplyChangesRoutesVerbs(t *testing.T) {
|
||||
{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 {
|
||||
if err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -136,7 +292,7 @@ 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) {
|
||||
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})
|
||||
@@ -144,7 +300,7 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T)
|
||||
}
|
||||
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"}}
|
||||
@@ -155,7 +311,7 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T)
|
||||
{Kind: diff.Add, Type: add.Type, Name: add.Name, Desired: &add},
|
||||
}}
|
||||
|
||||
err := c.ApplyChanges(context.Background(), creds(), "z1", cs)
|
||||
err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error when update rrset id is not found")
|
||||
}
|
||||
@@ -166,13 +322,13 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T)
|
||||
|
||||
// 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) {
|
||||
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"}}
|
||||
@@ -180,15 +336,16 @@ func TestApplyChangesDeleteIDNotFoundReturnsError(t *testing.T) {
|
||||
{Kind: diff.Delete, Type: missing.Type, Name: missing.Name, Actual: &missing},
|
||||
}}
|
||||
|
||||
if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err == nil {
|
||||
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 must be sent on mutating requests (POST/PATCH/DELETE), not only on GET.
|
||||
// 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(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
@@ -203,7 +360,7 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
|
||||
}
|
||||
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"}}
|
||||
@@ -216,15 +373,15 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
|
||||
{Kind: diff.Delete, Type: delActual.Type, Name: delActual.Name, Actual: &delActual},
|
||||
}}
|
||||
|
||||
if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil {
|
||||
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 != "secret-token" {
|
||||
t.Fatalf("expected X-Auth-Token %q on mutation, got %q", "secret-token", tok)
|
||||
if tok != testIdentityToken {
|
||||
t.Fatalf("expected X-Auth-Token %q on mutation, got %q", testIdentityToken, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +389,7 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
|
||||
// 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) {
|
||||
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 {
|
||||
@@ -252,10 +409,10 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
|
||||
default:
|
||||
t.Fatalf("unexpected offset %q", offset)
|
||||
}
|
||||
}))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
zs, err := c.ListZones(context.Background(), creds())
|
||||
zs, err := c.ListZones(context.Background(), testCreds())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -272,7 +429,7 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
|
||||
// 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) {
|
||||
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 {
|
||||
@@ -298,10 +455,10 @@ func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) {
|
||||
default:
|
||||
t.Fatalf("unexpected offset %q", offset)
|
||||
}
|
||||
}))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
recs, err := c.GetRecords(context.Background(), creds(), "z1")
|
||||
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -320,13 +477,13 @@ func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) {
|
||||
// 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) {
|
||||
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{
|
||||
@@ -339,24 +496,31 @@ func TestApplyChangesAddWithNilDesiredReturnsErrorNoPanic(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
err := c.ApplyChanges(context.Background(), creds(), "z1", cs)
|
||||
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 and a 30s HTTP timeout.
|
||||
// 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
|
||||
@@ -364,7 +528,7 @@ func TestNewDefaults(t *testing.T) {
|
||||
// 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) {
|
||||
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")
|
||||
@@ -386,10 +550,10 @@ func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) {
|
||||
default:
|
||||
t.Fatalf("unexpected offset %q", offset)
|
||||
}
|
||||
}))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
zs, err := c.ListZones(context.Background(), creds())
|
||||
zs, err := c.ListZones(context.Background(), testCreds())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -405,7 +569,7 @@ func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) {
|
||||
// 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) {
|
||||
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")
|
||||
@@ -433,10 +597,10 @@ func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) {
|
||||
default:
|
||||
t.Fatalf("unexpected offset %q", offset)
|
||||
}
|
||||
}))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
recs, err := c.GetRecords(context.Background(), creds(), "z1")
|
||||
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -451,13 +615,13 @@ func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) {
|
||||
// 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) {
|
||||
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(), creds())
|
||||
_, err := c.ListZones(context.Background(), testCreds())
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error on non-2xx response")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user