diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index 73c9f89..a3a6db7 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -161,6 +161,7 @@ func (mockProvider) GetRecords(context.Context, provider.Credentials, string) ([ func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error { return nil } +func (mockProvider) Validate(context.Context, provider.Credentials) error { return nil } // newTenantTestAPI wires a fixed authenticated user who owns whatever // project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f2a8f23..28287a8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,8 +7,9 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/model" ) -// Credentials holds the secret used to authenticate against a provider. -// For Selectel this is the project-scoped token sent as X-Auth-Token. +// Credentials holds the provider-specific secret. It is stored encrypted and, +// once decrypted, is a provider-defined value — for Selectel a JSON blob with +// the service-user credentials (see selectel.Creds). type Credentials struct { Secret string } @@ -25,4 +26,7 @@ type Provider interface { ListZones(ctx context.Context, creds Credentials) ([]Zone, error) GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error) ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) error + // Validate checks the credentials are usable (e.g. a trial auth), so a + // bad account is rejected at creation time rather than at first import. + Validate(ctx context.Context, creds Credentials) error } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 800aad0..479b297 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -21,6 +21,7 @@ func (stubProvider) GetRecords(context.Context, Credentials, string) ([]model.Re func (stubProvider) ApplyChanges(context.Context, Credentials, string, diff.Changeset) error { return nil } +func (stubProvider) Validate(context.Context, Credentials) error { return nil } func TestProviderInterfaceSatisfied(t *testing.T) { var p Provider = stubProvider{} diff --git a/internal/provider/registry/registry_test.go b/internal/provider/registry/registry_test.go index b720b3c..2ad7638 100644 --- a/internal/provider/registry/registry_test.go +++ b/internal/provider/registry/registry_test.go @@ -21,6 +21,7 @@ func (fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([ func (fakeProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error { return nil } +func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil } func TestRegistryByName(t *testing.T) { r := New() diff --git a/internal/provider/selectel/selectel.go b/internal/provider/selectel/selectel.go index 7056039..6add0e6 100644 --- a/internal/provider/selectel/selectel.go +++ b/internal/provider/selectel/selectel.go @@ -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 } } diff --git a/internal/provider/selectel/selectel_test.go b/internal/provider/selectel/selectel_test.go index a3a884d..91ca44f 100644 --- a/internal/provider/selectel/selectel_test.go +++ b/internal/provider/selectel/selectel_test.go @@ -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") } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index d3e7cf7..0da37d9 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -41,6 +41,7 @@ func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ f.applied = cs return nil } +func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil } type fakeLoader struct{ ref DomainRef }