From 9ea015d71c9b03ff6afcad952c6c968a3fd9efa3 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Tue, 16 Dec 2025 14:05:55 +0100 Subject: [PATCH 1/3] fix: enable admin api key on profile add --- .gitignore | 2 + pkg/cmd/apikeys/list/list.go | 9 ++++- pkg/cmd/apikeys/list/list_test.go | 5 +++ pkg/cmd/profile/add/add.go | 50 +++++++++++++++--------- pkg/cmd/profile/add/add_test.go | 64 ++++++++++++++++++++++++++++++- 5 files changed, 108 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index cb9a173e..8eccf948 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,7 @@ vendor/ # local build algolia +.gocache/ + # Environment variables *.env \ No newline at end of file diff --git a/pkg/cmd/apikeys/list/list.go b/pkg/cmd/apikeys/list/list.go index ec7c3f28..82adf5e4 100644 --- a/pkg/cmd/apikeys/list/list.go +++ b/pkg/cmd/apikeys/list/list.go @@ -16,6 +16,9 @@ import ( "github.com/algolia/cli/pkg/validators" ) +// nowFn exists to make time-based output deterministic in tests. +var nowFn = time.Now + type ListOptions struct { Config config.IConfig IO *iostreams.IOStreams @@ -62,6 +65,8 @@ func runListCmd(opts *ListOptions) error { return err } + now := nowFn() + opts.IO.StartProgressIndicatorWithLabel("Fetching API Keys") res, err := client.ListApiKeys() opts.IO.StopProgressIndicator() @@ -100,7 +105,7 @@ func runListCmd(opts *ListOptions) error { return "Never expire" } else { validity := time.Duration(*key.Validity) * time.Second - return humanize.Time(time.Now().Add(validity)) + return humanize.RelTime(now.Add(validity), now, "ago", "from now") } }(), nil, nil) if key.MaxHitsPerQuery == nil || *key.MaxHitsPerQuery == 0 { @@ -115,7 +120,7 @@ func runListCmd(opts *ListOptions) error { } table.AddField(fmt.Sprintf("%v", key.Referers), nil, nil) createdAt := time.Unix(key.CreatedAt, 0) - table.AddField(humanize.Time(createdAt), nil, nil) + table.AddField(humanize.RelTime(createdAt, now, "ago", "from now"), nil, nil) table.EndRow() } return table.Render() diff --git a/pkg/cmd/apikeys/list/list_test.go b/pkg/cmd/apikeys/list/list_test.go index 287e9a22..953f813c 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -2,6 +2,7 @@ package list import ( "testing" + "time" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/stretchr/testify/assert" @@ -30,6 +31,10 @@ func Test_runListCmd(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + oldNowFn := nowFn + nowFn = func() time.Time { return time.Unix(1735689600, 0) } // 2025-01-01T00:00:00Z + t.Cleanup(func() { nowFn = oldNowFn }) + name := "test" r := httpmock.Registry{} r.Register( diff --git a/pkg/cmd/profile/add/add.go b/pkg/cmd/profile/add/add.go index eaf8f9ad..1a2209ac 100644 --- a/pkg/cmd/profile/add/add.go +++ b/pkg/cmd/profile/add/add.go @@ -18,6 +18,34 @@ import ( "github.com/algolia/cli/pkg/validators" ) +type apiKeyInspector interface { + ListApiKeys(opts ...search.RequestOption) (*search.ListApiKeysResponse, error) + GetApiKey(r search.ApiGetApiKeyRequest, opts ...search.RequestOption) (*search.GetApiKeyResponse, error) + NewApiGetApiKeyRequest(key string) search.ApiGetApiKeyRequest +} + +func inspectAPIKey(client apiKeyInspector, key string) (isAdmin bool, stringACLs []string, err error) { + // Admin API keys are special: they can list keys but aren't themselves retrievable via GET /1/keys/{key}. + // So we use ListApiKeys() as the admin-key check and skip GetApiKey() in that case. + if _, err := client.ListApiKeys(); err == nil { + return true, nil, nil + } + + apiKey, err := client.GetApiKey(client.NewApiGetApiKeyRequest(key)) + if err != nil { + return false, nil, errors.New("invalid application credentials") + } + if len(apiKey.Acl) == 0 { + return false, nil, errors.New("the provided API key has no ACLs") + } + + for _, a := range apiKey.Acl { + stringACLs = append(stringACLs, string(a)) + } + + return false, stringACLs, nil +} + // AddOptions represents the options for the add command type AddOptions struct { config config.IConfig @@ -128,7 +156,7 @@ func runAddCmd(opts *AddOptions) error { { Name: "APIKey", Prompt: &survey.Input{ - Message: "(Write) API Key:", + Message: "Write API Key:", Default: opts.Profile.APIKey, }, Validate: survey.Required, @@ -151,25 +179,9 @@ func runAddCmd(opts *AddOptions) error { if err != nil { return err } - var isAdminAPIKey bool - - // Check if the provided API Key is an admin API Key - _, err = client.ListApiKeys() - if err == nil { - isAdminAPIKey = true - } - - // Check the ACLs of the provided API Key - apiKey, err := client.GetApiKey(client.NewApiGetApiKeyRequest(opts.Profile.APIKey)) + isAdminAPIKey, stringACLs, err := inspectAPIKey(client, opts.Profile.APIKey) if err != nil { - return errors.New("invalid application credentials") - } - if len(apiKey.Acl) == 0 { - return errors.New("the provided API key has no ACLs") - } - var stringACLs []string - for _, a := range apiKey.Acl { - stringACLs = append(stringACLs, string(a)) + return err } // We should have at least the ACLs for a write key, otherwise warns the user, but still allows to add the profile. diff --git a/pkg/cmd/profile/add/add_test.go b/pkg/cmd/profile/add/add_test.go index 1ba92aa4..18054caa 100644 --- a/pkg/cmd/profile/add/add_test.go +++ b/pkg/cmd/profile/add/add_test.go @@ -1,12 +1,14 @@ package add import ( + "errors" "testing" "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" @@ -97,8 +99,68 @@ func TestNewAddCmd(t *testing.T) { assert.Equal(t, tt.wantsOpts.Profile.Name, opts.Profile.Name) assert.Equal(t, tt.wantsOpts.Profile.ApplicationID, opts.Profile.ApplicationID) - assert.Equal(t, tt.wantsOpts.Profile.AdminAPIKey, opts.Profile.AdminAPIKey) + assert.Equal(t, tt.wantsOpts.Profile.APIKey, opts.Profile.APIKey) assert.Equal(t, tt.wantsOpts.Profile.Default, opts.Profile.Default) }) } } + +type stubAPIKeyInspector struct { + listErr error + + getResp *search.GetApiKeyResponse + getErr error + getCalled bool +} + +func (s *stubAPIKeyInspector) ListApiKeys(opts ...search.RequestOption) (*search.ListApiKeysResponse, error) { + return &search.ListApiKeysResponse{}, s.listErr +} + +func (s *stubAPIKeyInspector) GetApiKey(r search.ApiGetApiKeyRequest, opts ...search.RequestOption) (*search.GetApiKeyResponse, error) { + s.getCalled = true + return s.getResp, s.getErr +} + +func (s *stubAPIKeyInspector) NewApiGetApiKeyRequest(key string) search.ApiGetApiKeyRequest { + return search.ApiGetApiKeyRequest{} +} + +func TestInspectAPIKey_AdminKeySkipsGetApiKey(t *testing.T) { + stub := &stubAPIKeyInspector{ + listErr: nil, // admin keys can list API keys + getErr: errors.New("should not be called"), + } + + isAdmin, acls, err := inspectAPIKey(stub, "my-admin-key") + require.NoError(t, err) + assert.True(t, isAdmin) + assert.Nil(t, acls) + assert.False(t, stub.getCalled) +} + +func TestInspectAPIKey_NonAdminKeyReturnsACLs(t *testing.T) { + stub := &stubAPIKeyInspector{ + listErr: errors.New("API error [403] forbidden"), // non-admin keys cannot list API keys + getResp: &search.GetApiKeyResponse{ + Acl: []search.Acl{search.ACL_SEARCH, search.ACL_ADD_OBJECT}, + }, + } + + isAdmin, acls, err := inspectAPIKey(stub, "my-write-key") + require.NoError(t, err) + assert.False(t, isAdmin) + assert.Equal(t, []string{"search", "addObject"}, acls) + assert.True(t, stub.getCalled) +} + +func TestInspectAPIKey_InvalidCredentials(t *testing.T) { + stub := &stubAPIKeyInspector{ + listErr: errors.New("API error [403] invalid"), // fall back to GetApiKey + getErr: errors.New("API error [403] invalid"), + } + + _, _, err := inspectAPIKey(stub, "bad-key") + require.Error(t, err) + assert.Equal(t, "invalid application credentials", err.Error()) +} From e44f23a8523c3a42772bada113337c8f4f203636 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Tue, 16 Dec 2025 14:09:59 +0100 Subject: [PATCH 2/3] fix: rel time issue --- pkg/cmd/apikeys/list/list.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/apikeys/list/list.go b/pkg/cmd/apikeys/list/list.go index 82adf5e4..8fa1c601 100644 --- a/pkg/cmd/apikeys/list/list.go +++ b/pkg/cmd/apikeys/list/list.go @@ -105,7 +105,7 @@ func runListCmd(opts *ListOptions) error { return "Never expire" } else { validity := time.Duration(*key.Validity) * time.Second - return humanize.RelTime(now.Add(validity), now, "ago", "from now") + return humanize.RelTime(now, now.Add(validity), "from now", "ago") } }(), nil, nil) if key.MaxHitsPerQuery == nil || *key.MaxHitsPerQuery == 0 { @@ -120,7 +120,7 @@ func runListCmd(opts *ListOptions) error { } table.AddField(fmt.Sprintf("%v", key.Referers), nil, nil) createdAt := time.Unix(key.CreatedAt, 0) - table.AddField(humanize.RelTime(createdAt, now, "ago", "from now"), nil, nil) + table.AddField(humanize.RelTime(now, createdAt, "from now", "ago"), nil, nil) table.EndRow() } return table.Render() From 63c6eacc2a31b43db103b1092a58e38b9d357381 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Tue, 16 Dec 2025 14:15:00 +0100 Subject: [PATCH 3/3] fix lint issues --- pkg/cmd/profile/add/add.go | 32 +++++++++++++++++++++++++------- pkg/cmd/profile/add/add_test.go | 6 +++--- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/profile/add/add.go b/pkg/cmd/profile/add/add.go index 1a2209ac..bab975d3 100644 --- a/pkg/cmd/profile/add/add.go +++ b/pkg/cmd/profile/add/add.go @@ -19,19 +19,36 @@ import ( ) type apiKeyInspector interface { - ListApiKeys(opts ...search.RequestOption) (*search.ListApiKeysResponse, error) - GetApiKey(r search.ApiGetApiKeyRequest, opts ...search.RequestOption) (*search.GetApiKeyResponse, error) - NewApiGetApiKeyRequest(key string) search.ApiGetApiKeyRequest + ListAPIKeys(opts ...search.RequestOption) (*search.ListApiKeysResponse, error) + GetAPIKey(r search.ApiGetApiKeyRequest, opts ...search.RequestOption) (*search.GetApiKeyResponse, error) + NewAPIGetAPIKeyRequest(key string) search.ApiGetApiKeyRequest +} + +// searchClientAdapter adapts the Algolia search client to our apiKeyInspector interface +type searchClientAdapter struct { + client *search.APIClient +} + +func (a *searchClientAdapter) ListAPIKeys(opts ...search.RequestOption) (*search.ListApiKeysResponse, error) { + return a.client.ListApiKeys(opts...) +} + +func (a *searchClientAdapter) GetAPIKey(r search.ApiGetApiKeyRequest, opts ...search.RequestOption) (*search.GetApiKeyResponse, error) { + return a.client.GetApiKey(r, opts...) +} + +func (a *searchClientAdapter) NewAPIGetAPIKeyRequest(key string) search.ApiGetApiKeyRequest { + return a.client.NewApiGetApiKeyRequest(key) } func inspectAPIKey(client apiKeyInspector, key string) (isAdmin bool, stringACLs []string, err error) { // Admin API keys are special: they can list keys but aren't themselves retrievable via GET /1/keys/{key}. - // So we use ListApiKeys() as the admin-key check and skip GetApiKey() in that case. - if _, err := client.ListApiKeys(); err == nil { + // So we use ListAPIKeys() as the admin-key check and skip GetAPIKey() in that case. + if _, err := client.ListAPIKeys(); err == nil { return true, nil, nil } - apiKey, err := client.GetApiKey(client.NewApiGetApiKeyRequest(key)) + apiKey, err := client.GetAPIKey(client.NewAPIGetAPIKeyRequest(key)) if err != nil { return false, nil, errors.New("invalid application credentials") } @@ -179,7 +196,8 @@ func runAddCmd(opts *AddOptions) error { if err != nil { return err } - isAdminAPIKey, stringACLs, err := inspectAPIKey(client, opts.Profile.APIKey) + adapter := &searchClientAdapter{client: client} + isAdminAPIKey, stringACLs, err := inspectAPIKey(adapter, opts.Profile.APIKey) if err != nil { return err } diff --git a/pkg/cmd/profile/add/add_test.go b/pkg/cmd/profile/add/add_test.go index 18054caa..04a4215b 100644 --- a/pkg/cmd/profile/add/add_test.go +++ b/pkg/cmd/profile/add/add_test.go @@ -113,16 +113,16 @@ type stubAPIKeyInspector struct { getCalled bool } -func (s *stubAPIKeyInspector) ListApiKeys(opts ...search.RequestOption) (*search.ListApiKeysResponse, error) { +func (s *stubAPIKeyInspector) ListAPIKeys(opts ...search.RequestOption) (*search.ListApiKeysResponse, error) { return &search.ListApiKeysResponse{}, s.listErr } -func (s *stubAPIKeyInspector) GetApiKey(r search.ApiGetApiKeyRequest, opts ...search.RequestOption) (*search.GetApiKeyResponse, error) { +func (s *stubAPIKeyInspector) GetAPIKey(r search.ApiGetApiKeyRequest, opts ...search.RequestOption) (*search.GetApiKeyResponse, error) { s.getCalled = true return s.getResp, s.getErr } -func (s *stubAPIKeyInspector) NewApiGetApiKeyRequest(key string) search.ApiGetApiKeyRequest { +func (s *stubAPIKeyInspector) NewAPIGetAPIKeyRequest(key string) search.ApiGetApiKeyRequest { return search.ApiGetApiKeyRequest{} }