Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ vendor/
# local build
algolia

.gocache/

# Environment variables
*.env
9 changes: 7 additions & 2 deletions pkg/cmd/apikeys/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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, now.Add(validity), "from now", "ago")
}
}(), nil, nil)
if key.MaxHitsPerQuery == nil || *key.MaxHitsPerQuery == 0 {
Expand All @@ -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(now, createdAt, "from now", "ago"), nil, nil)
table.EndRow()
}
return table.Render()
Expand Down
5 changes: 5 additions & 0 deletions pkg/cmd/apikeys/list/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package list

import (
"testing"
"time"

"github.com/algolia/algoliasearch-client-go/v4/algolia/search"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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(
Expand Down
68 changes: 49 additions & 19 deletions pkg/cmd/profile/add/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,51 @@ 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
}

// 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 {
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
Expand Down Expand Up @@ -128,7 +173,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,
Expand All @@ -151,25 +196,10 @@ 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))
adapter := &searchClientAdapter{client: client}
isAdminAPIKey, stringACLs, err := inspectAPIKey(adapter, 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.
Expand Down
64 changes: 63 additions & 1 deletion pkg/cmd/profile/add/add_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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())
}
Loading