From f544c77491cf1544e61b78490d3bd6e442b1d21f Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 12:38:00 -0700 Subject: [PATCH 1/7] feat(apikeys): list keys with the signed-in session --- api/dashboard/client.go | 70 ++++- api/dashboard/client_test.go | 188 ++++++++++++ api/dashboard/types.go | 10 + pkg/cmd/apikeys/create/create.go | 6 + pkg/cmd/apikeys/list/list.go | 290 +++++++++++++++--- pkg/cmd/apikeys/list/list_test.go | 477 +++++++++++++++++++++++++++++- 6 files changed, 993 insertions(+), 48 deletions(-) diff --git a/api/dashboard/client.go b/api/dashboard/client.go index a26cb899..2d482521 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -557,6 +557,74 @@ func (c *Client) CreateAPIKey( return CreatedAPIKey{Value: key.Value, UUID: key.UUID}, nil } +func (c *Client) ListAPIKeys(accessToken, appID string) ([]APIKey, error) { + allKeys := []APIKey{} + + for page := 1; ; page++ { + keysResp, err := c.listAPIKeysPage(accessToken, appID, page) + if err != nil { + return nil, err + } + + for i := range keysResp.Data { + allKeys = append(allKeys, keysResp.Data[i].toAPIKey()) + } + + if len(keysResp.Data) == 0 || page >= keysResp.Meta.TotalPages { + return allKeys, nil + } + } +} + +func notFoundError(body io.Reader) error { + raw, err := io.ReadAll(body) + if err != nil || !json.Valid(bytes.TrimSpace(raw)) { + return ErrEndpointNotAvailable + } + + return ErrApplicationNotFound +} + +func (c *Client) listAPIKeysPage( + accessToken, appID string, + page int, +) (*APIKeysResponse, error) { + endpoint := fmt.Sprintf( + "%s/1/applications/%s/api-keys?page=%d", + c.APIURL, + url.PathEscape(appID), + page, + ) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + c.setAPIHeaders(req, accessToken) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("list API keys request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, ErrSessionExpired + } + if resp.StatusCode == http.StatusNotFound { + return nil, notFoundError(resp.Body) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("list API keys failed with status: %d", resp.StatusCode) + } + + var keysResp APIKeysResponse + if err := json.NewDecoder(resp.Body).Decode(&keysResp); err != nil { + return nil, fmt.Errorf("failed to parse API keys response: %w", err) + } + + return &keysResp, nil +} + func (c *Client) CreateAPIKeyWithParams( accessToken, appID string, params CreateAPIKeyRequest, @@ -584,7 +652,7 @@ func (c *Client) CreateAPIKeyWithParams( return APIKey{}, ErrSessionExpired } if resp.StatusCode == http.StatusNotFound { - return APIKey{}, ErrApplicationNotFound + return APIKey{}, notFoundError(resp.Body) } respBody, err := io.ReadAll(resp.Body) diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index b3315cfe..cb5a66b1 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -338,6 +338,178 @@ func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "no key was returned") } +func TestListAPIKeys_FollowsPagination(t *testing.T) { + var requestedPages []string + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + page := r.URL.Query().Get("page") + requestedPages = append(requestedPages, page) + require.LessOrEqual(t, len(requestedPages), 3, "the pagination loop is unbounded") + + resource := APIKeyResource{ + ID: "uuid-" + page, + Type: "api_key", + Attributes: APIKeyAttributes{ + Value: "key-" + page, + ACL: []string{"search"}, + }, + } + + current := 1 + if page == "2" { + current = 2 + } + + require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ + Data: []APIKeyResource{resource}, + Meta: PaginationMeta{CurrentPage: current, TotalPages: 2, TotalCount: 2, PerPage: 1}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.NoError(t, err) + + assert.Equal(t, []string{"1", "2"}, requestedPages) + require.Len(t, keys, 2) + assert.Equal(t, "uuid-1", keys[0].UUID) + assert.Equal(t, "key-1", keys[0].Value) + assert.Equal(t, "uuid-2", keys[1].UUID) + assert.Equal(t, []string{"search"}, keys[1].ACL) +} + +func TestListAPIKeys_StopsWhenTheServerRepeatsThePage(t *testing.T) { + var requests int + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + requests++ + require.LessOrEqual(t, requests, 3, "the pagination loop is unbounded") + + require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ + Data: []APIKeyResource{{ + ID: "uuid-1", + Type: "api_key", + Attributes: APIKeyAttributes{Value: "key-1"}, + }}, + Meta: PaginationMeta{CurrentPage: 1, TotalPages: 3, TotalCount: 3, PerPage: 1}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.NoError(t, err) + assert.Equal(t, 3, requests) + assert.Len(t, keys, 3) +} + +func TestListAPIKeys_StopsOnAnEmptyPage(t *testing.T) { + var requests int + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + requests++ + require.LessOrEqual(t, requests, 2, "an empty page must stop the pagination loop") + + data := []APIKeyResource{{ + ID: "uuid-1", + Type: "api_key", + Attributes: APIKeyAttributes{Value: "key-1"}, + }} + if r.URL.Query().Get("page") != "1" { + data = nil + } + + require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ + Data: data, + Meta: PaginationMeta{CurrentPage: 1, TotalPages: 10, TotalCount: 1, PerPage: 1}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.NoError(t, err) + assert.Equal(t, 2, requests) + assert.Len(t, keys, 1) +} + +func TestListAPIKeys_NoKeys(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ + Data: []APIKeyResource{}, + Meta: PaginationMeta{CurrentPage: 1, TotalPages: 0, TotalCount: 0, PerPage: 15}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.NoError(t, err) + assert.Empty(t, keys) + + marshalled, err := json.Marshal(keys) + require.NoError(t, err) + assert.Equal(t, "[]", string(marshalled)) +} + +func TestListAPIKeys_Errors(t *testing.T) { + tests := []struct { + name string + status int + body string + wantErr error + }{ + { + name: "unauthorized", + status: http.StatusUnauthorized, + wantErr: ErrSessionExpired, + }, + { + name: "unknown application", + status: http.StatusNotFound, + body: `{"errors":[{"status":"404","title":"Not Found"}]}`, + wantErr: ErrApplicationNotFound, + }, + { + name: "endpoint not routed", + status: http.StatusNotFound, + body: "The page you were looking for doesn't exist.", + wantErr: ErrEndpointNotAvailable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc( + "/1/applications/APP1/api-keys", + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + }, + ) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.ListAPIKeys("test-token", "APP1") + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + func TestCreateAPIKeyWithParams_SendsAllParamsAndReturnsTheKey(t *testing.T) { var got CreateAPIKeyRequest @@ -420,6 +592,22 @@ func TestCreateAPIKeyWithParams_ApplicationNotFound(t *testing.T) { require.ErrorIs(t, err, ErrApplicationNotFound) } +func TestCreateAPIKeyWithParams_EndpointNotRouted(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Not found")) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + }) + require.ErrorIs(t, err, ErrEndpointNotAvailable) +} + func TestRotateAPIKey_ReturnsNewValue(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc( diff --git a/api/dashboard/types.go b/api/dashboard/types.go index 3f4ae54c..3c86cd2c 100644 --- a/api/dashboard/types.go +++ b/api/dashboard/types.go @@ -109,6 +109,8 @@ var ErrSessionExpired = errors.New("session expired") var ErrApplicationNotFound = errors.New("application not found") +var ErrEndpointNotAvailable = errors.New("API endpoint not available") + // ErrClusterUnavailable is returned when a region has no available cluster. type ErrClusterUnavailable struct { Region string @@ -151,6 +153,7 @@ type APIKeyAttributes struct { MaxHitsPerQuery *int `json:"max_hits_per_query"` MaxQueriesPerIPPerHour *int `json:"max_queries_per_ip_per_hour"` QueryParameters *string `json:"query_parameters"` + CreatedAt string `json:"created_at"` } type APIKey struct { @@ -164,6 +167,7 @@ type APIKey struct { MaxHitsPerQuery *int `json:"max_hits_per_query,omitempty"` MaxQueriesPerIPPerHour *int `json:"max_queries_per_ip_per_hour,omitempty"` QueryParameters *string `json:"query_parameters,omitempty"` + CreatedAt string `json:"created_at,omitempty"` } // CreateAPIKeyResponse is the JSON:API response from POST /1/applications/{application_id}/api-keys. @@ -171,6 +175,11 @@ type CreateAPIKeyResponse struct { Data APIKeyResource `json:"data"` } +type APIKeysResponse struct { + Data []APIKeyResource `json:"data"` + Meta PaginationMeta `json:"meta"` +} + // CreatedAPIKey is the result of creating an API key: its secret value and its // UUID, used to reference the key when persisting or managing it later. type CreatedAPIKey struct { @@ -213,6 +222,7 @@ func (r *APIKeyResource) toAPIKey() APIKey { MaxHitsPerQuery: r.Attributes.MaxHitsPerQuery, MaxQueriesPerIPPerHour: r.Attributes.MaxQueriesPerIPPerHour, QueryParameters: r.Attributes.QueryParameters, + CreatedAt: r.Attributes.CreatedAt, } } diff --git a/pkg/cmd/apikeys/create/create.go b/pkg/cmd/apikeys/create/create.go index 443960d0..07ec1b60 100644 --- a/pkg/cmd/apikeys/create/create.go +++ b/pkg/cmd/apikeys/create/create.go @@ -239,6 +239,12 @@ func runCreateWithDashboardAPI(opts *CreateOptions) error { key, err := createKeyWithSession(opts, client, appID, params) if err != nil { + if errors.Is(err, dashboard.ErrEndpointNotAvailable) { + return fmt.Errorf( + "creating API keys with your signed-in session needs a newer Algolia API version than the one answering: pass %s with an admin key in the meantime", + cs.Bold("--api-key"), + ) + } if errors.Is(err, dashboard.ErrApplicationNotFound) { return fmt.Errorf( "application %s doesn't exist, or your account doesn't have access to it: run %s to pick one of your applications", diff --git a/pkg/cmd/apikeys/list/list.go b/pkg/cmd/apikeys/list/list.go index 60c37879..9d1bb094 100644 --- a/pkg/cmd/apikeys/list/list.go +++ b/pkg/cmd/apikeys/list/list.go @@ -1,14 +1,19 @@ package list import ( + "errors" "fmt" + "net/http" "sort" "time" + "github.com/MakeNowJust/heredoc" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/dustin/go-humanize" "github.com/spf13/cobra" + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/auth" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" @@ -19,11 +24,25 @@ import ( // nowFn exists to make time-based output deterministic in tests. var nowFn = time.Now +var tableHeaders = []string{ + "KEY", + "DESCRIPTION", + "ACL", + "INDICES", + "VALIDITY", + "MAX HITS PER QUERY", + "MAX QUERIES PER IP PER HOUR", + "REFERERS", + "CREATED AT", +} + type ListOptions struct { Config config.IConfig IO *iostreams.IOStreams - SearchClient func() (*search.APIClient, error) + SearchClient func() (*search.APIClient, error) + NewDashboardClient func(clientID string) *dashboard.Client + LoadToken func() *auth.StoredToken PrintFlags *cmdutil.PrintFlags } @@ -34,16 +53,39 @@ func NewListCmd(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman IO: f.IOStreams, Config: f.Config, SearchClient: f.SearchClient, - PrintFlags: cmdutil.NewPrintFlags(), + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + LoadToken: auth.LoadToken, + PrintFlags: cmdutil.NewPrintFlags(), } cmd := &cobra.Command{ Use: "list", Aliases: []string{"l"}, Args: validators.NoArgs(), Annotations: map[string]string{ - "acls": "admin", + "skipAuthCheck": "true", }, Short: "Lists all API keys associated with your Algolia application, including their permissions and restrictions.", + Long: heredoc.Doc(` + Lists all API keys associated with your Algolia application, including their permissions and restrictions. + + By default, the keys of the current application are listed through your + signed-in session, so no admin API key is needed. This only covers the keys + created by the CLI, and doesn't report an expiry. Keys you don't have the + rights to create are listed without their value. + + When the API key in use isn't the one the CLI provisioned for the current + application (--api-key, ALGOLIA_API_KEY, or a key stored by a config.toml + profile), every key of the application is listed with the Search API. + `), + Example: heredoc.Doc(` + # List the API keys the CLI created for the current application + $ algolia apikeys list + + # List every API key of the application + $ algolia apikeys list --api-key + `), RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) @@ -60,6 +102,105 @@ func NewListCmd(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman // runListCmd executes the list command func runListCmd(opts *ListOptions) error { + if !config.ShouldUseSessionAPIKey(opts.Config) { + return runListWithSearchAPI(opts) + } + + if opts.LoadToken() == nil { + return runListWithSearchAPI(opts) + } + + return runListWithSessionAPI(opts) +} + +func runListWithSessionAPI(opts *ListOptions) error { + cs := opts.IO.ColorScheme() + + appID, err := opts.Config.Profile().GetApplicationID() + if err != nil { + return fmt.Errorf( + "no application selected: run %s, or pass --application-id", + cs.Bold("algolia application select"), + ) + } + + client := opts.NewDashboardClient(auth.OAuthClientID()) + + keys, err := listKeysWithSession(opts, client, appID) + if err != nil { + if errors.Is(err, dashboard.ErrEndpointNotAvailable) { + return fmt.Errorf( + "listing API keys with your signed-in session needs a newer Algolia API version than the one answering: pass %s with an admin key in the meantime", + cs.Bold("--api-key"), + ) + } + if errors.Is(err, dashboard.ErrApplicationNotFound) { + return fmt.Errorf( + "application %s doesn't exist, or your account doesn't have access to it: run %s to pick one of your applications", + cs.Bold(appID), + cs.Bold("algolia application select"), + ) + } + return err + } + + if opts.PrintFlags.HasStructuredOutput() { + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + return p.Print(opts.IO, keys) + } + + now := nowFn() + rows := make([][]string, 0, len(keys)) + for _, key := range keys { + rows = append(rows, []string{ + formatKeyValue(key.Value), + key.Description, + fmt.Sprintf("%v", key.ACL), + fmt.Sprintf("%v", key.Indexes), + "-", + formatLimit(key.MaxHitsPerQuery), + formatLimit(key.MaxQueriesPerIPPerHour), + fmt.Sprintf("%v", key.Referers), + formatCreatedAt(now, key.CreatedAt), + }) + } + + return renderTable(opts.IO, rows) +} + +func listKeysWithSession( + opts *ListOptions, + client *dashboard.Client, + appID string, +) ([]dashboard.APIKey, error) { + accessToken, err := auth.EnsureAuthenticated(opts.IO, client) + if err != nil { + return nil, err + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching API Keys") + keys, err := client.ListAPIKeys(accessToken, appID) + opts.IO.StopProgressIndicator() + if err == nil { + return keys, nil + } + + accessToken, err = auth.ReauthenticateIfExpired(opts.IO, client, err) + if err != nil { + return nil, err + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching API Keys") + keys, err = client.ListAPIKeys(accessToken, appID) + opts.IO.StopProgressIndicator() + + return keys, err +} + +func runListWithSearchAPI(opts *ListOptions) error { client, err := opts.SearchClient() if err != nil { return err @@ -71,7 +212,7 @@ func runListCmd(opts *ListOptions) error { res, err := client.ListApiKeys() opts.IO.StopProgressIndicator() if err != nil { - return err + return searchAPIListError(opts, err) } if opts.PrintFlags.HasStructuredOutput() { @@ -82,54 +223,121 @@ func runListCmd(opts *ListOptions) error { return p.Print(opts.IO, res) } - table := printers.NewTablePrinter(opts.IO) - if table.IsTTY() { - table.AddField("KEY", nil, nil) - table.AddField("DESCRIPTION", nil, nil) - table.AddField("ACL", nil, nil) - table.AddField("INDICES", nil, nil) - table.AddField("VALIDITY", nil, nil) - table.AddField("MAX HITS PER QUERY", nil, nil) - table.AddField("MAX QUERIES PER IP PER HOUR", nil, nil) - table.AddField("REFERERS", nil, nil) - table.AddField("CREATED AT", nil, nil) - table.EndRow() - } - // Sort API Keys by createdAt sort.Slice(res.Keys, func(i, j int) bool { return res.Keys[i].CreatedAt > res.Keys[j].CreatedAt }) + rows := make([][]string, 0, len(res.Keys)) for _, key := range res.Keys { - table.AddField(key.Value, nil, nil) + description := "" if key.Description != nil { - table.AddField(*key.Description, nil, nil) + description = *key.Description } - table.AddField(fmt.Sprintf("%v", key.Acl), nil, nil) - table.AddField(fmt.Sprintf("%v", key.Indexes), nil, nil) - table.AddField(func() string { - if key.Validity == nil || *key.Validity == 0 { - return "Never expire" - } else { - validity := time.Duration(*key.Validity) * time.Second - return humanize.RelTime(now, now.Add(validity), "from now", "ago") - } - }(), nil, nil) - if key.MaxHitsPerQuery == nil || *key.MaxHitsPerQuery == 0 { - table.AddField("0", nil, nil) - } else { - table.AddField(humanize.Comma(int64(*key.MaxHitsPerQuery)), nil, nil) + + rows = append(rows, []string{ + key.Value, + description, + fmt.Sprintf("%v", key.Acl), + fmt.Sprintf("%v", key.Indexes), + formatValidity(now, key.Validity), + formatLimit(intFromInt32(key.MaxHitsPerQuery)), + formatLimit(intFromInt32(key.MaxQueriesPerIPPerHour)), + fmt.Sprintf("%v", key.Referers), + humanize.RelTime(now, time.Unix(key.CreatedAt, 0), "from now", "ago"), + }) + } + + return renderTable(opts.IO, rows) +} + +func renderTable(io *iostreams.IOStreams, rows [][]string) error { + table := printers.NewTablePrinter(io) + if table.IsTTY() { + for _, header := range tableHeaders { + table.AddField(header, nil, nil) } - if key.MaxQueriesPerIPPerHour == nil || *key.MaxQueriesPerIPPerHour == 0 { - table.AddField("0", nil, nil) - } else { - table.AddField(humanize.Comma(int64(*key.MaxQueriesPerIPPerHour)), nil, nil) + table.EndRow() + } + + for _, row := range rows { + for _, field := range row { + table.AddField(field, nil, nil) } - table.AddField(fmt.Sprintf("%v", key.Referers), nil, nil) - createdAt := time.Unix(key.CreatedAt, 0) - table.AddField(humanize.RelTime(now, createdAt, "from now", "ago"), nil, nil) table.EndRow() } + return table.Render() } + +func formatValidity(now time.Time, validity *int32) string { + if validity == nil || *validity == 0 { + return "Never expire" + } + + duration := time.Duration(*validity) * time.Second + + return humanize.RelTime(now, now.Add(duration), "from now", "ago") +} + +func formatKeyValue(value string) string { + if value == "" { + return "-" + } + + return value +} + +func formatLimit(limit *int) string { + if limit == nil || *limit == 0 { + return "0" + } + + return humanize.Comma(int64(*limit)) +} + +func formatCreatedAt(now time.Time, createdAt string) string { + if createdAt == "" { + return "" + } + + parsed, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return createdAt + } + + return humanize.RelTime(now, parsed, "from now", "ago") +} + +func intFromInt32(value *int32) *int { + if value == nil { + return nil + } + + converted := int(*value) + + return &converted +} + +func searchAPIListError(opts *ListOptions, err error) error { + var apiErr *search.APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusForbidden { + return err + } + + cs := opts.IO.ColorScheme() + if config.ShouldUseSessionAPIKey(opts.Config) { + return fmt.Errorf( + "%w\nRun %s to list API keys without an admin key", + err, + cs.Bold("algolia auth login"), + ) + } + + return fmt.Errorf( + "%w\nThe API key in use isn't an admin key. Provide an admin key, or drop the key set through %s, %s or your profile to list the keys the CLI created for your signed-in session", + err, + cs.Bold("--api-key"), + cs.Bold("ALGOLIA_API_KEY"), + ) +} diff --git a/pkg/cmd/apikeys/list/list_test.go b/pkg/cmd/apikeys/list/list_test.go index 9d3b8633..e6f01c95 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -1,16 +1,161 @@ package list import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" "testing" "time" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/test" ) +func freezeNow(t *testing.T) { + t.Helper() + oldNowFn := nowFn + nowFn = func() time.Time { return time.Unix(1735689600, 0) } // 2025-01-01T00:00:00Z + t.Cleanup(func() { nowFn = oldNowFn }) +} + +func withoutSession(t *testing.T) { + t.Helper() + t.Setenv("ALGOLIA_API_KEY", "") + t.Setenv("ALGOLIA_ADMIN_API_KEY", "") + t.Setenv("ALGOLIA_APPLICATION_ID", "") + keyring.MockInit() +} + +func managedKeyConfig() *test.ConfigStub { + return &test.ConfigStub{ + CurrentProfile: config.Profile{ApplicationID: "APP1"}, + ActiveAppID: "APP1", + SavedApps: map[string]test.SavedApplication{ + "APP1": {APIKeyUUID: "uuid-1", APIKey: "cli-key"}, + }, + } +} + +func unusedDashboardClient(t *testing.T) func(string) *dashboard.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected dashboard request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + return func(string) *dashboard.Client { + t.Error("the dashboard client must not be used on the Search API path") + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + } +} + +func withSession(t *testing.T) { + t.Helper() + withoutSession(t) + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "tok-1", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) +} + +// listKeysServer stubs the dashboard list endpoint at wantPath, serving pages +// out of the given resource batches. +func listKeysServer( + t *testing.T, + wantPath string, + pages [][]dashboard.APIKeyResource, +) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + requests := 0 + mux.HandleFunc(wantPath, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + + requests++ + require.LessOrEqual(t, requests, len(pages)+1, "the pagination loop is unbounded") + + page := 1 + if r.URL.Query().Get("page") == "2" { + page = 2 + } + + require.NoError(t, json.NewEncoder(w).Encode(dashboard.APIKeysResponse{ + Data: pages[page-1], + Meta: dashboard.PaginationMeta{ + CurrentPage: page, + TotalPages: len(pages), + TotalCount: len(pages[page-1]), + PerPage: 15, + }, + })) + }) + return httptest.NewServer(mux) +} + +func newSessionOpts( + t *testing.T, + srv *httptest.Server, + isTTY bool, +) (*ListOptions, *bytes.Buffer) { + t.Helper() + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(isTTY) + + opts := &ListOptions{ + IO: io, + Config: managedKeyConfig(), + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + }, + LoadToken: auth.LoadToken, + PrintFlags: cmdutil.NewPrintFlags(), + } + return opts, stdout +} + +func sessionKey(uuid, value, description string) dashboard.APIKeyResource { + return dashboard.APIKeyResource{ + ID: uuid, + Type: "api_key", + Attributes: dashboard.APIKeyAttributes{ + Value: value, + ACL: []string{"search"}, + Description: description, + Indexes: []string{}, + Referers: []string{}, + CreatedAt: "2020-01-01T00:00:00.000Z", + }, + } +} + +func TestNewListCmd_SkipsTheAdminACLCheck(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: io} + cmd := NewListCmd(f, nil) + + assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) + assert.Empty(t, cmd.Annotations["acls"]) +} + func Test_runListCmd(t *testing.T) { tests := []struct { name string @@ -31,9 +176,8 @@ 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 }) + withoutSession(t) + freezeNow(t) name := "test" r := httpmock.Registry{} @@ -64,9 +208,8 @@ func Test_runListCmd(t *testing.T) { } func Test_runListCmd_outputJSON(t *testing.T) { - oldNowFn := nowFn - nowFn = func() time.Time { return time.Unix(1735689600, 0) } // 2025-01-01T00:00:00Z - t.Cleanup(func() { nowFn = oldNowFn }) + withoutSession(t) + freezeNow(t) name := "test" r := httpmock.Registry{} @@ -94,3 +237,325 @@ func Test_runListCmd_outputJSON(t *testing.T) { assert.Contains(t, out.String(), `"keys":[`) assert.Contains(t, out.String(), `"value":"foo"`) } + +func Test_runListCmd_ExplicitAPIKeyUsesTheSearchAPI(t *testing.T) { + withSession(t) + freezeNow(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{{Value: "from-sapi"}}, + }), + ) + + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "admin-key" + + f, out := test.NewFactory(false, &r, cfg, "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Contains(t, out.String(), "from-sapi") +} + +func Test_runListCmd_EnvAPIKeyUsesTheSearchAPI(t *testing.T) { + withSession(t) + freezeNow(t) + t.Setenv("ALGOLIA_API_KEY", "env-admin-key") + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{{Value: "from-sapi"}}, + }), + ) + + f, out := test.NewFactory(false, &r, managedKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Contains(t, out.String(), "from-sapi") +} + +func Test_runListCmd_WithSession(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "search-key", "frontend")}, + }) + defer srv.Close() + + opts, stdout := newSessionOpts(t, srv, false) + + require.NoError(t, runListCmd(opts)) + + assert.Equal( + t, + "search-key\tfrontend\t[search]\t[]\t-\t0\t0\t[]\t5 years ago\n", + stdout.String(), + ) +} + +func Test_runListCmd_WithSessionFollowsPagination(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "key-1", "first")}, + {sessionKey("uuid-2", "key-2", "second")}, + }) + defer srv.Close() + + opts, stdout := newSessionOpts(t, srv, false) + + require.NoError(t, runListCmd(opts)) + + assert.Contains(t, stdout.String(), "key-1") + assert.Contains(t, stdout.String(), "key-2") +} + +func Test_runListCmd_WithSessionStructuredOutput(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "search-key", "frontend")}, + }) + defer srv.Close() + + opts, stdout := newSessionOpts(t, srv, false) + format := "json" + opts.PrintFlags.OutputFormat = &format + + require.NoError(t, runListCmd(opts)) + + var keys []dashboard.APIKey + require.NoError(t, json.Unmarshal(stdout.Bytes(), &keys)) + require.Len(t, keys, 1) + assert.Equal(t, "uuid-1", keys[0].UUID) + assert.Equal(t, "search-key", keys[0].Value) + assert.Equal(t, []string{"search"}, keys[0].ACL) +} + +func Test_runListCmd_WithSessionEmpty(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) + defer srv.Close() + + opts, stdout := newSessionOpts(t, srv, false) + + require.NoError(t, runListCmd(opts)) + assert.Equal(t, "", stdout.String()) +} + +func Test_runListCmd_WithSessionUnknownApplication(t *testing.T) { + withSession(t) + freezeNow(t) + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errors":[{"status":"404","title":"Not Found"}]}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + opts, _ := newSessionOpts(t, srv, false) + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "APP1") + assert.Contains(t, err.Error(), "doesn't have access to it") +} + +func Test_runListCmd_SignedInWithoutAnApplication(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) + defer srv.Close() + + opts, _ := newSessionOpts(t, srv, false) + opts.Config = &test.ConfigStub{} + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no application selected") + assert.Contains(t, err.Error(), "algolia application select") +} + +func Test_runListCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "search-key", "frontend")}, + }) + defer srv.Close() + + opts, stdout := newSessionOpts(t, srv, false) + opts.Config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} + + require.NoError(t, runListCmd(opts)) + + assert.Contains(t, stdout.String(), "search-key") +} + +func Test_runListCmd_SearchAPIKeyWithoutADescription(t *testing.T) { + withoutSession(t) + freezeNow(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{ + { + Value: "foo", + Acl: []search.Acl{search.ACL_SEARCH}, + CreatedAt: 1577836800, + }, + }, + }), + ) + + f, out := test.NewFactory(false, &r, nil, "") + cmd := NewListCmd(f, nil) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Equal( + t, + "foo\t\t[search]\t[]\tNever expire\t0\t0\t[]\t5 years ago\n", + out.String(), + ) +} + +func Test_runListCmd_WithSessionEndpointNotAvailable(t *testing.T) { + withSession(t) + freezeNow(t) + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Not found")) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + opts, _ := newSessionOpts(t, srv, false) + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "newer Algolia API version") + assert.NotContains(t, err.Error(), "doesn't have access to it") +} + +func Test_searchAPIListError(t *testing.T) { + forbidden := &search.APIError{Status: http.StatusForbidden, Message: "Not enough rights"} + + t.Run("403 with an admin key in play", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "weak-key" + opts := &ListOptions{IO: io, Config: cfg} + + err := searchAPIListError(opts, forbidden) + require.Error(t, err) + assert.Contains(t, err.Error(), "isn't an admin key") + assert.Contains(t, err.Error(), "ALGOLIA_API_KEY") + assert.NotContains(t, err.Error(), "algolia auth login") + }) + + t.Run("403 with the CLI-managed key", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + opts := &ListOptions{IO: io, Config: managedKeyConfig()} + + err := searchAPIListError(opts, forbidden) + require.Error(t, err) + assert.Contains(t, err.Error(), "algolia auth login") + assert.NotContains(t, err.Error(), "--api-key") + }) + + t.Run("non-403 errors pass through", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + opts := &ListOptions{IO: io, Config: managedKeyConfig()} + + other := &search.APIError{Status: http.StatusBadRequest, Message: "nope"} + assert.Same(t, other, searchAPIListError(opts, other)) + + plain := errors.New("boom") + assert.Same(t, plain, searchAPIListError(opts, plain)) + }) +} + +func Test_formatCreatedAt(t *testing.T) { + now := time.Unix(1735689600, 0) + + assert.Equal(t, "5 years ago", formatCreatedAt(now, "2020-01-01T00:00:00.000Z")) + assert.Equal(t, "", formatCreatedAt(now, "")) + assert.Equal(t, "not-a-date", formatCreatedAt(now, "not-a-date")) +} + +func Test_runListCmd_WithSessionMaskedKeyValue(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "", "restricted key")}, + }) + defer srv.Close() + + opts, stdout := newSessionOpts(t, srv, false) + + require.NoError(t, runListCmd(opts)) + + assert.Equal( + t, + "-\trestricted key\t[search]\t[]\t-\t0\t0\t[]\t5 years ago\n", + stdout.String(), + ) +} + +func Test_runListCmd_WithSessionMaskedKeyValueStructuredOutput(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "", "restricted key")}, + }) + defer srv.Close() + + opts, stdout := newSessionOpts(t, srv, false) + format := "json" + opts.PrintFlags.OutputFormat = &format + + require.NoError(t, runListCmd(opts)) + + var keys []map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &keys)) + require.Len(t, keys, 1) + assert.NotContains(t, keys[0], "value") + assert.Equal(t, "uuid-1", keys[0]["uuid"]) + assert.Equal(t, "restricted key", keys[0]["description"]) +} From c7e37007f17c961ccbb06294819b5199522a1391 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 13:41:16 -0700 Subject: [PATCH 2/7] fix(apikeys): make list pagination and 404 handling strict --- api/dashboard/client.go | 28 +- api/dashboard/client_test.go | 71 ++++- pkg/cmd/apikeys/list/list.go | 59 +++-- pkg/cmd/apikeys/list/list_test.go | 413 ++++++++++++++++++++++++++++-- 4 files changed, 520 insertions(+), 51 deletions(-) diff --git a/api/dashboard/client.go b/api/dashboard/client.go index 2d482521..c8a3e95d 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -566,11 +566,27 @@ func (c *Client) ListAPIKeys(accessToken, appID string) ([]APIKey, error) { return nil, err } + if len(keysResp.Data) == 0 { + return allKeys, nil + } + + if keysResp.Meta.TotalPages <= 0 { + return nil, fmt.Errorf( + "list API keys returned %d keys on page %d without pagination metadata", + len(keysResp.Data), + page, + ) + } + + if keysResp.Meta.CurrentPage != 0 && keysResp.Meta.CurrentPage != page { + return allKeys, nil + } + for i := range keysResp.Data { allKeys = append(allKeys, keysResp.Data[i].toAPIKey()) } - if len(keysResp.Data) == 0 || page >= keysResp.Meta.TotalPages { + if page >= keysResp.Meta.TotalPages { return allKeys, nil } } @@ -578,7 +594,15 @@ func (c *Client) ListAPIKeys(accessToken, appID string) ([]APIKey, error) { func notFoundError(body io.Reader) error { raw, err := io.ReadAll(body) - if err != nil || !json.Valid(bytes.TrimSpace(raw)) { + if err != nil { + return ErrEndpointNotAvailable + } + + var envelope struct { + Errors []json.RawMessage `json:"errors"` + } + if err := json.Unmarshal(bytes.TrimSpace(raw), &envelope); err != nil || + len(envelope.Errors) == 0 { return ErrEndpointNotAvailable } diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index cb5a66b1..ce82607b 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -407,8 +407,34 @@ func TestListAPIKeys_StopsWhenTheServerRepeatsThePage(t *testing.T) { keys, err := client.ListAPIKeys("test-token", "APP1") require.NoError(t, err) - assert.Equal(t, 3, requests) - assert.Len(t, keys, 3) + assert.Equal(t, 2, requests) + require.Len(t, keys, 1) + assert.Equal(t, "uuid-1", keys[0].UUID) +} + +func TestListAPIKeys_ErrorsWhenAPageHasNoPaginationMetadata(t *testing.T) { + var requests int + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + requests++ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "data": []APIKeyResource{{ + ID: "uuid-1", + Type: "api_key", + Attributes: APIKeyAttributes{Value: "key-1"}, + }}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.Error(t, err) + assert.Contains(t, err.Error(), "without pagination metadata") + assert.Nil(t, keys) + assert.Equal(t, 1, requests) } func TestListAPIKeys_StopsOnAnEmptyPage(t *testing.T) { @@ -488,6 +514,47 @@ func TestListAPIKeys_Errors(t *testing.T) { body: "The page you were looking for doesn't exist.", wantErr: ErrEndpointNotAvailable, }, + { + name: "empty body", + status: http.StatusNotFound, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "empty JSON object", + status: http.StatusNotFound, + body: `{}`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "JSON null", + status: http.StatusNotFound, + body: `null`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "JSON number", + status: http.StatusNotFound, + body: `123`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "JSON string", + status: http.StatusNotFound, + body: `"Not Found"`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "Rails unrouted path", + status: http.StatusNotFound, + body: `{"status":404,"error":"Not Found"}`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "empty JSON:API errors array", + status: http.StatusNotFound, + body: `{"errors":[]}`, + wantErr: ErrEndpointNotAvailable, + }, } for _, tt := range tests { diff --git a/pkg/cmd/apikeys/list/list.go b/pkg/cmd/apikeys/list/list.go index 9d1bb094..7b7d32ec 100644 --- a/pkg/cmd/apikeys/list/list.go +++ b/pkg/cmd/apikeys/list/list.go @@ -24,6 +24,8 @@ import ( // nowFn exists to make time-based output deterministic in tests. var nowFn = time.Now +var reauthenticate = auth.ReauthenticateIfExpired + var tableHeaders = []string{ "KEY", "DESCRIPTION", @@ -42,7 +44,6 @@ type ListOptions struct { SearchClient func() (*search.APIClient, error) NewDashboardClient func(clientID string) *dashboard.Client - LoadToken func() *auth.StoredToken PrintFlags *cmdutil.PrintFlags } @@ -56,7 +57,6 @@ func NewListCmd(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID) }, - LoadToken: auth.LoadToken, PrintFlags: cmdutil.NewPrintFlags(), } cmd := &cobra.Command{ @@ -75,9 +75,14 @@ func NewListCmd(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman created by the CLI, and doesn't report an expiry. Keys you don't have the rights to create are listed without their value. - When the API key in use isn't the one the CLI provisioned for the current - application (--api-key, ALGOLIA_API_KEY, or a key stored by a config.toml - profile), every key of the application is listed with the Search API. + Every key of the application is listed with the Search API instead + whenever the API key in use isn't the one the CLI provisioned for the + current application: --api-key, ALGOLIA_API_KEY, a key stored by a + config.toml profile, or a key kept in your keychain that the CLI didn't + create. + + --admin-api-key and ALGOLIA_ADMIN_API_KEY are ignored while an application + is selected. `), Example: heredoc.Doc(` # List the API keys the CLI created for the current application @@ -106,16 +111,25 @@ func runListCmd(opts *ListOptions) error { return runListWithSearchAPI(opts) } - if opts.LoadToken() == nil { - return runListWithSearchAPI(opts) + return runListWithSessionAPI(opts) +} + +func structuredPrinter(opts *ListOptions) (printers.Printer, error) { + if !opts.PrintFlags.HasStructuredOutput() { + return nil, nil } - return runListWithSessionAPI(opts) + return opts.PrintFlags.ToPrinter() } func runListWithSessionAPI(opts *ListOptions) error { cs := opts.IO.ColorScheme() + printer, err := structuredPrinter(opts) + if err != nil { + return err + } + appID, err := opts.Config.Profile().GetApplicationID() if err != nil { return fmt.Errorf( @@ -144,12 +158,8 @@ func runListWithSessionAPI(opts *ListOptions) error { return err } - if opts.PrintFlags.HasStructuredOutput() { - p, err := opts.PrintFlags.ToPrinter() - if err != nil { - return err - } - return p.Print(opts.IO, keys) + if printer != nil { + return printer.Print(opts.IO, keys) } now := nowFn() @@ -188,7 +198,7 @@ func listKeysWithSession( return keys, nil } - accessToken, err = auth.ReauthenticateIfExpired(opts.IO, client, err) + accessToken, err = reauthenticate(opts.IO, client, err) if err != nil { return nil, err } @@ -201,11 +211,16 @@ func listKeysWithSession( } func runListWithSearchAPI(opts *ListOptions) error { - client, err := opts.SearchClient() + printer, err := structuredPrinter(opts) if err != nil { return err } + client, err := opts.SearchClient() + if err != nil { + return auth.WithRemediation(err) + } + now := nowFn() opts.IO.StartProgressIndicatorWithLabel("Fetching API Keys") @@ -215,12 +230,8 @@ func runListWithSearchAPI(opts *ListOptions) error { return searchAPIListError(opts, err) } - if opts.PrintFlags.HasStructuredOutput() { - p, err := opts.PrintFlags.ToPrinter() - if err != nil { - return err - } - return p.Print(opts.IO, res) + if printer != nil { + return printer.Print(opts.IO, res) } // Sort API Keys by createdAt @@ -236,7 +247,7 @@ func runListWithSearchAPI(opts *ListOptions) error { } rows = append(rows, []string{ - key.Value, + formatKeyValue(key.Value), description, fmt.Sprintf("%v", key.Acl), fmt.Sprintf("%v", key.Indexes), @@ -298,7 +309,7 @@ func formatLimit(limit *int) string { func formatCreatedAt(now time.Time, createdAt string) string { if createdAt == "" { - return "" + return "-" } parsed, err := time.Parse(time.RFC3339, createdAt) diff --git a/pkg/cmd/apikeys/list/list_test.go b/pkg/cmd/apikeys/list/list_test.go index e6f01c95..82f490c0 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -23,6 +23,14 @@ import ( "github.com/algolia/cli/test" ) +const unroutableAPIURL = "http://127.0.0.1:1" + +type ttys struct { + stdin bool + stdout bool + stderr bool +} + func freezeNow(t *testing.T) { t.Helper() oldNowFn := nowFn @@ -30,11 +38,21 @@ func freezeNow(t *testing.T) { t.Cleanup(func() { nowFn = oldNowFn }) } +func stubReauthenticate(t *testing.T, token string, err error) { + t.Helper() + old := reauthenticate + reauthenticate = func(*iostreams.IOStreams, *dashboard.Client, error) (string, error) { + return token, err + } + t.Cleanup(func() { reauthenticate = old }) +} + func withoutSession(t *testing.T) { t.Helper() t.Setenv("ALGOLIA_API_KEY", "") t.Setenv("ALGOLIA_ADMIN_API_KEY", "") t.Setenv("ALGOLIA_APPLICATION_ID", "") + t.Setenv("ALGOLIA_API_URL", unroutableAPIURL) keyring.MockInit() } @@ -48,6 +66,13 @@ func managedKeyConfig() *test.ConfigStub { } } +func explicitKeyConfig() *test.ConfigStub { + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "adm" + + return cfg +} + func unusedDashboardClient(t *testing.T) func(string) *dashboard.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -111,12 +136,14 @@ func listKeysServer( func newSessionOpts( t *testing.T, srv *httptest.Server, - isTTY bool, -) (*ListOptions, *bytes.Buffer) { + tty ttys, +) (*ListOptions, *bytes.Buffer, *bytes.Buffer) { t.Helper() - io, _, stdout, _ := iostreams.Test() - io.SetStdoutTTY(isTTY) + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(tty.stdin) + io.SetStdoutTTY(tty.stdout) + io.SetStderrTTY(tty.stderr) opts := &ListOptions{ IO: io, @@ -126,10 +153,9 @@ func newSessionOpts( c.APIURL = srv.URL return c }, - LoadToken: auth.LoadToken, PrintFlags: cmdutil.NewPrintFlags(), } - return opts, stdout + return opts, stdout, stderr } func sessionKey(uuid, value, description string) dashboard.APIKeyResource { @@ -195,8 +221,11 @@ func Test_runListCmd(t *testing.T) { }), ) - f, out := test.NewFactory(tt.isTTY, &r, nil, "") - cmd := NewListCmd(f, nil) + f, out := test.NewFactory(tt.isTTY, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) out, err := test.Execute(cmd, "", out) if err != nil { t.Fatal(err) @@ -227,8 +256,11 @@ func Test_runListCmd_outputJSON(t *testing.T) { }), ) - f, out := test.NewFactory(false, &r, nil, "") - cmd := NewListCmd(f, nil) + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) out, err := test.Execute(cmd, "--output json", out) if err != nil { t.Fatal(err) @@ -297,7 +329,7 @@ func Test_runListCmd_WithSession(t *testing.T) { }) defer srv.Close() - opts, stdout := newSessionOpts(t, srv, false) + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) require.NoError(t, runListCmd(opts)) @@ -318,7 +350,7 @@ func Test_runListCmd_WithSessionFollowsPagination(t *testing.T) { }) defer srv.Close() - opts, stdout := newSessionOpts(t, srv, false) + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) require.NoError(t, runListCmd(opts)) @@ -335,7 +367,7 @@ func Test_runListCmd_WithSessionStructuredOutput(t *testing.T) { }) defer srv.Close() - opts, stdout := newSessionOpts(t, srv, false) + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) format := "json" opts.PrintFlags.OutputFormat = &format @@ -356,7 +388,7 @@ func Test_runListCmd_WithSessionEmpty(t *testing.T) { srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) defer srv.Close() - opts, stdout := newSessionOpts(t, srv, false) + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) require.NoError(t, runListCmd(opts)) assert.Equal(t, "", stdout.String()) @@ -374,7 +406,7 @@ func Test_runListCmd_WithSessionUnknownApplication(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - opts, _ := newSessionOpts(t, srv, false) + opts, _, _ := newSessionOpts(t, srv, ttys{}) err := runListCmd(opts) require.Error(t, err) @@ -389,7 +421,7 @@ func Test_runListCmd_SignedInWithoutAnApplication(t *testing.T) { srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) defer srv.Close() - opts, _ := newSessionOpts(t, srv, false) + opts, _, _ := newSessionOpts(t, srv, ttys{}) opts.Config = &test.ConfigStub{} err := runListCmd(opts) @@ -407,7 +439,7 @@ func Test_runListCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testing }) defer srv.Close() - opts, stdout := newSessionOpts(t, srv, false) + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) opts.Config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} require.NoError(t, runListCmd(opts)) @@ -433,8 +465,11 @@ func Test_runListCmd_SearchAPIKeyWithoutADescription(t *testing.T) { }), ) - f, out := test.NewFactory(false, &r, nil, "") - cmd := NewListCmd(f, nil) + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) out, err := test.Execute(cmd, "", out) require.NoError(t, err) @@ -457,7 +492,7 @@ func Test_runListCmd_WithSessionEndpointNotAvailable(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - opts, _ := newSessionOpts(t, srv, false) + opts, _, _ := newSessionOpts(t, srv, ttys{}) err := runListCmd(opts) require.Error(t, err) @@ -513,10 +548,29 @@ func Test_formatCreatedAt(t *testing.T) { now := time.Unix(1735689600, 0) assert.Equal(t, "5 years ago", formatCreatedAt(now, "2020-01-01T00:00:00.000Z")) - assert.Equal(t, "", formatCreatedAt(now, "")) + assert.Equal(t, "-", formatCreatedAt(now, "")) assert.Equal(t, "not-a-date", formatCreatedAt(now, "not-a-date")) } +func Test_formatLimit(t *testing.T) { + zero := 0 + large := 1234567 + + assert.Equal(t, "0", formatLimit(nil)) + assert.Equal(t, "0", formatLimit(&zero)) + assert.Equal(t, "1,234,567", formatLimit(&large)) +} + +func Test_formatValidity(t *testing.T) { + now := time.Unix(1735689600, 0) + zero := int32(0) + hour := int32(3600) + + assert.Equal(t, "Never expire", formatValidity(now, nil)) + assert.Equal(t, "Never expire", formatValidity(now, &zero)) + assert.Equal(t, "1 hour from now", formatValidity(now, &hour)) +} + func Test_runListCmd_WithSessionMaskedKeyValue(t *testing.T) { withSession(t) freezeNow(t) @@ -526,7 +580,7 @@ func Test_runListCmd_WithSessionMaskedKeyValue(t *testing.T) { }) defer srv.Close() - opts, stdout := newSessionOpts(t, srv, false) + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) require.NoError(t, runListCmd(opts)) @@ -546,7 +600,7 @@ func Test_runListCmd_WithSessionMaskedKeyValueStructuredOutput(t *testing.T) { }) defer srv.Close() - opts, stdout := newSessionOpts(t, srv, false) + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) format := "json" opts.PrintFlags.OutputFormat = &format @@ -559,3 +613,316 @@ func Test_runListCmd_WithSessionMaskedKeyValueStructuredOutput(t *testing.T) { assert.Equal(t, "uuid-1", keys[0]["uuid"]) assert.Equal(t, "restricted key", keys[0]["description"]) } + +func Test_runListCmd_SearchAPIMaskedKeyValue(t *testing.T) { + withoutSession(t) + freezeNow(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{{ + Acl: []search.Acl{search.ACL_SEARCH}, + CreatedAt: 1577836800, + }}, + }), + ) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Equal( + t, + "-\t\t[search]\t[]\tNever expire\t0\t0\t[]\t5 years ago\n", + out.String(), + ) +} + +func Test_runListCmd_SearchAPIListsLimitsAndValidity(t *testing.T) { + withoutSession(t) + freezeNow(t) + + maxHits := int32(1234) + maxQueries := int32(5678) + validity := int32(3600) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{{ + Value: "foo", + Acl: []search.Acl{search.ACL_SEARCH}, + Validity: &validity, + MaxHitsPerQuery: &maxHits, + MaxQueriesPerIPPerHour: &maxQueries, + CreatedAt: 1577836800, + }}, + }), + ) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Equal( + t, + "foo\t\t[search]\t[]\t1 hour from now\t1,234\t5,678\t[]\t5 years ago\n", + out.String(), + ) +} + +func Test_runListCmd_WithSessionListsLimits(t *testing.T) { + withSession(t) + freezeNow(t) + + maxHits := 1234 + maxQueries := 5678 + resource := sessionKey("uuid-1", "search-key", "frontend") + resource.Attributes.MaxHitsPerQuery = &maxHits + resource.Attributes.MaxQueriesPerIPPerHour = &maxQueries + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {resource}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + + assert.Equal( + t, + "search-key\tfrontend\t[search]\t[]\t-\t1,234\t5,678\t[]\t5 years ago\n", + stdout.String(), + ) +} + +func Test_runListCmd_WithSessionWithoutACreationDate(t *testing.T) { + withSession(t) + freezeNow(t) + + resource := sessionKey("uuid-1", "search-key", "frontend") + resource.Attributes.CreatedAt = "" + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {resource}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + + assert.Equal( + t, + "search-key\tfrontend\t[search]\t[]\t-\t0\t0\t[]\t-\n", + stdout.String(), + ) +} + +func Test_runListCmd_WithSessionRetriesAfterAnExpiredSession(t *testing.T) { + withSession(t) + freezeNow(t) + stubReauthenticate(t, "tok-2", nil) + + requests := 0 + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests == 1 { + assert.Equal(t, "Bearer tok-1", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusUnauthorized) + return + } + + assert.Equal(t, "Bearer tok-2", r.Header.Get("Authorization")) + require.NoError(t, json.NewEncoder(w).Encode(dashboard.APIKeysResponse{ + Data: []dashboard.APIKeyResource{sessionKey("uuid-1", "search-key", "frontend")}, + Meta: dashboard.PaginationMeta{ + CurrentPage: 1, + TotalPages: 1, + TotalCount: 1, + PerPage: 15, + }, + })) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + + assert.Equal(t, 2, requests) + assert.Contains(t, stdout.String(), "search-key") +} + +func Test_runListCmd_WithSessionExpiredWithoutATerminal(t *testing.T) { + withSession(t) + freezeNow(t) + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + opts, stdout, stderr := newSessionOpts(t, srv, ttys{stdin: true, stderr: true}) + require.False(t, opts.IO.CanPrompt()) + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") + assert.Contains(t, err.Error(), "algolia auth login") + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "Session expired") + + stored := auth.LoadToken() + require.NotNil(t, stored) + assert.Equal(t, "tok-1", stored.AccessToken) +} + +func Test_runListCmd_UnsupportedOutputFormatFailsBeforeListing(t *testing.T) { + t.Run("session path", func(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := httptest.NewServer( + http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("no request must be made: %s %s", r.Method, r.URL.Path) + }), + ) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + format := "xml" + opts.PrintFlags.OutputFormat = &format + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to match a printer") + assert.Empty(t, stdout.String()) + }) + + t.Run("search API path", func(t *testing.T) { + withoutSession(t) + freezeNow(t) + + r := httpmock.Registry{} + r.Register(httpmock.REST("GET", "1/keys"), func(*http.Request) (*http.Response, error) { + t.Error("no listing must be requested") + return nil, errors.New("unexpected request") + }) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + _, err := test.Execute(cmd, "-o xml", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to match a printer") + assert.Empty(t, out.String()) + }) +} + +func Test_runListCmd_WithoutASessionOrAnApplication(t *testing.T) { + withoutSession(t) + freezeNow(t) + + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("no request must be made: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts.Config = &test.ConfigStub{} + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no application selected") + assert.Contains(t, err.Error(), "algolia application select") + assert.Empty(t, stdout.String()) +} + +func Test_runListCmd_WithoutASessionStdoutPipedStderrTTY(t *testing.T) { + withoutSession(t) + freezeNow(t) + + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("no request must be made without a session: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + opts, stdout, stderr := newSessionOpts(t, srv, ttys{stdin: true, stderr: true}) + require.False(t, opts.IO.CanPrompt()) + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") + assert.Contains(t, err.Error(), "algolia auth login") + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "not logged in") + + prompting, _, _ := newSessionOpts(t, srv, ttys{stdin: true, stdout: true, stderr: true}) + assert.True(t, prompting.IO.CanPrompt()) +} + +func Test_runListCmd_SearchAPIClientErrorSurfacesTheRemediation(t *testing.T) { + tests := []struct { + name string + session bool + want string + }{ + { + name: "signed out", + want: "algolia auth login", + }, + { + name: "signed in", + session: true, + want: "algolia application select", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.session { + withSession(t) + } else { + withoutSession(t) + } + t.Setenv("ALGOLIA_API_KEY", "adm") + freezeNow(t) + + io, _, stdout, _ := iostreams.Test() + opts := &ListOptions{ + IO: io, + Config: &test.ConfigStub{}, + SearchClient: func() (*search.APIClient, error) { + return nil, config.ErrApplicationIDNotConfigured + }, + NewDashboardClient: unusedDashboardClient(t), + PrintFlags: cmdutil.NewPrintFlags(), + } + + err := runListCmd(opts) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrApplicationIDNotConfigured) + assert.Contains(t, err.Error(), tt.want) + assert.Empty(t, stdout.String()) + }) + } +} From 53725db94ba1697c4ede4c3215639d84caef542d Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 13:43:44 -0700 Subject: [PATCH 3/7] refactor(apikeys): inject the list reauth retry via options --- pkg/cmd/apikeys/list/list.go | 8 ++++---- pkg/cmd/apikeys/list/list_test.go | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/apikeys/list/list.go b/pkg/cmd/apikeys/list/list.go index 7b7d32ec..88d8e611 100644 --- a/pkg/cmd/apikeys/list/list.go +++ b/pkg/cmd/apikeys/list/list.go @@ -24,8 +24,6 @@ import ( // nowFn exists to make time-based output deterministic in tests. var nowFn = time.Now -var reauthenticate = auth.ReauthenticateIfExpired - var tableHeaders = []string{ "KEY", "DESCRIPTION", @@ -44,6 +42,7 @@ type ListOptions struct { SearchClient func() (*search.APIClient, error) NewDashboardClient func(clientID string) *dashboard.Client + Reauthenticate func(*iostreams.IOStreams, *dashboard.Client, error) (string, error) PrintFlags *cmdutil.PrintFlags } @@ -57,7 +56,8 @@ func NewListCmd(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID) }, - PrintFlags: cmdutil.NewPrintFlags(), + Reauthenticate: auth.ReauthenticateIfExpired, + PrintFlags: cmdutil.NewPrintFlags(), } cmd := &cobra.Command{ Use: "list", @@ -198,7 +198,7 @@ func listKeysWithSession( return keys, nil } - accessToken, err = reauthenticate(opts.IO, client, err) + accessToken, err = opts.Reauthenticate(opts.IO, client, err) if err != nil { return nil, err } diff --git a/pkg/cmd/apikeys/list/list_test.go b/pkg/cmd/apikeys/list/list_test.go index 82f490c0..ebc1c36a 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -38,15 +38,6 @@ func freezeNow(t *testing.T) { t.Cleanup(func() { nowFn = oldNowFn }) } -func stubReauthenticate(t *testing.T, token string, err error) { - t.Helper() - old := reauthenticate - reauthenticate = func(*iostreams.IOStreams, *dashboard.Client, error) (string, error) { - return token, err - } - t.Cleanup(func() { reauthenticate = old }) -} - func withoutSession(t *testing.T) { t.Helper() t.Setenv("ALGOLIA_API_KEY", "") @@ -153,7 +144,8 @@ func newSessionOpts( c.APIURL = srv.URL return c }, - PrintFlags: cmdutil.NewPrintFlags(), + Reauthenticate: auth.ReauthenticateIfExpired, + PrintFlags: cmdutil.NewPrintFlags(), } return opts, stdout, stderr } @@ -734,7 +726,6 @@ func Test_runListCmd_WithSessionWithoutACreationDate(t *testing.T) { func Test_runListCmd_WithSessionRetriesAfterAnExpiredSession(t *testing.T) { withSession(t) freezeNow(t) - stubReauthenticate(t, "tok-2", nil) requests := 0 mux := http.NewServeMux() @@ -761,9 +752,21 @@ func Test_runListCmd_WithSessionRetriesAfterAnExpiredSession(t *testing.T) { defer srv.Close() opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + reauthentications := 0 + opts.Reauthenticate = func( + _ *iostreams.IOStreams, + _ *dashboard.Client, + err error, + ) (string, error) { + require.ErrorIs(t, err, dashboard.ErrSessionExpired) + reauthentications++ + + return "tok-2", nil + } require.NoError(t, runListCmd(opts)) + assert.Equal(t, 1, reauthentications) assert.Equal(t, 2, requests) assert.Contains(t, stdout.String(), "search-key") } From 82e9aae2ed69e23d2cdfe3f6818a29b15398c3e0 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 14:00:45 -0700 Subject: [PATCH 4/7] test(apikeys): drop the list fix tests for a follow-up --- api/dashboard/client_test.go | 66 ------ pkg/cmd/apikeys/list/list_test.go | 381 ++---------------------------- 2 files changed, 15 insertions(+), 432 deletions(-) diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index ce82607b..7d5bd070 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -412,31 +412,6 @@ func TestListAPIKeys_StopsWhenTheServerRepeatsThePage(t *testing.T) { assert.Equal(t, "uuid-1", keys[0].UUID) } -func TestListAPIKeys_ErrorsWhenAPageHasNoPaginationMetadata(t *testing.T) { - var requests int - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - requests++ - require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ - "data": []APIKeyResource{{ - ID: "uuid-1", - Type: "api_key", - Attributes: APIKeyAttributes{Value: "key-1"}, - }}, - })) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - keys, err := client.ListAPIKeys("test-token", "APP1") - require.Error(t, err) - assert.Contains(t, err.Error(), "without pagination metadata") - assert.Nil(t, keys) - assert.Equal(t, 1, requests) -} - func TestListAPIKeys_StopsOnAnEmptyPage(t *testing.T) { var requests int @@ -514,47 +489,6 @@ func TestListAPIKeys_Errors(t *testing.T) { body: "The page you were looking for doesn't exist.", wantErr: ErrEndpointNotAvailable, }, - { - name: "empty body", - status: http.StatusNotFound, - wantErr: ErrEndpointNotAvailable, - }, - { - name: "empty JSON object", - status: http.StatusNotFound, - body: `{}`, - wantErr: ErrEndpointNotAvailable, - }, - { - name: "JSON null", - status: http.StatusNotFound, - body: `null`, - wantErr: ErrEndpointNotAvailable, - }, - { - name: "JSON number", - status: http.StatusNotFound, - body: `123`, - wantErr: ErrEndpointNotAvailable, - }, - { - name: "JSON string", - status: http.StatusNotFound, - body: `"Not Found"`, - wantErr: ErrEndpointNotAvailable, - }, - { - name: "Rails unrouted path", - status: http.StatusNotFound, - body: `{"status":404,"error":"Not Found"}`, - wantErr: ErrEndpointNotAvailable, - }, - { - name: "empty JSON:API errors array", - status: http.StatusNotFound, - body: `{"errors":[]}`, - wantErr: ErrEndpointNotAvailable, - }, } for _, tt := range tests { diff --git a/pkg/cmd/apikeys/list/list_test.go b/pkg/cmd/apikeys/list/list_test.go index ebc1c36a..02cc68c4 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -25,12 +25,6 @@ import ( const unroutableAPIURL = "http://127.0.0.1:1" -type ttys struct { - stdin bool - stdout bool - stderr bool -} - func freezeNow(t *testing.T) { t.Helper() oldNowFn := nowFn @@ -127,14 +121,12 @@ func listKeysServer( func newSessionOpts( t *testing.T, srv *httptest.Server, - tty ttys, -) (*ListOptions, *bytes.Buffer, *bytes.Buffer) { + isTTY bool, +) (*ListOptions, *bytes.Buffer) { t.Helper() - io, _, stdout, stderr := iostreams.Test() - io.SetStdinTTY(tty.stdin) - io.SetStdoutTTY(tty.stdout) - io.SetStderrTTY(tty.stderr) + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(isTTY) opts := &ListOptions{ IO: io, @@ -147,7 +139,7 @@ func newSessionOpts( Reauthenticate: auth.ReauthenticateIfExpired, PrintFlags: cmdutil.NewPrintFlags(), } - return opts, stdout, stderr + return opts, stdout } func sessionKey(uuid, value, description string) dashboard.APIKeyResource { @@ -321,7 +313,7 @@ func Test_runListCmd_WithSession(t *testing.T) { }) defer srv.Close() - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts, stdout := newSessionOpts(t, srv, false) require.NoError(t, runListCmd(opts)) @@ -342,7 +334,7 @@ func Test_runListCmd_WithSessionFollowsPagination(t *testing.T) { }) defer srv.Close() - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts, stdout := newSessionOpts(t, srv, false) require.NoError(t, runListCmd(opts)) @@ -359,7 +351,7 @@ func Test_runListCmd_WithSessionStructuredOutput(t *testing.T) { }) defer srv.Close() - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts, stdout := newSessionOpts(t, srv, false) format := "json" opts.PrintFlags.OutputFormat = &format @@ -380,7 +372,7 @@ func Test_runListCmd_WithSessionEmpty(t *testing.T) { srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) defer srv.Close() - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts, stdout := newSessionOpts(t, srv, false) require.NoError(t, runListCmd(opts)) assert.Equal(t, "", stdout.String()) @@ -398,7 +390,7 @@ func Test_runListCmd_WithSessionUnknownApplication(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - opts, _, _ := newSessionOpts(t, srv, ttys{}) + opts, _ := newSessionOpts(t, srv, false) err := runListCmd(opts) require.Error(t, err) @@ -413,7 +405,7 @@ func Test_runListCmd_SignedInWithoutAnApplication(t *testing.T) { srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) defer srv.Close() - opts, _, _ := newSessionOpts(t, srv, ttys{}) + opts, _ := newSessionOpts(t, srv, false) opts.Config = &test.ConfigStub{} err := runListCmd(opts) @@ -431,7 +423,7 @@ func Test_runListCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testing }) defer srv.Close() - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts, stdout := newSessionOpts(t, srv, false) opts.Config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} require.NoError(t, runListCmd(opts)) @@ -484,7 +476,7 @@ func Test_runListCmd_WithSessionEndpointNotAvailable(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - opts, _, _ := newSessionOpts(t, srv, ttys{}) + opts, _ := newSessionOpts(t, srv, false) err := runListCmd(opts) require.Error(t, err) @@ -544,25 +536,6 @@ func Test_formatCreatedAt(t *testing.T) { assert.Equal(t, "not-a-date", formatCreatedAt(now, "not-a-date")) } -func Test_formatLimit(t *testing.T) { - zero := 0 - large := 1234567 - - assert.Equal(t, "0", formatLimit(nil)) - assert.Equal(t, "0", formatLimit(&zero)) - assert.Equal(t, "1,234,567", formatLimit(&large)) -} - -func Test_formatValidity(t *testing.T) { - now := time.Unix(1735689600, 0) - zero := int32(0) - hour := int32(3600) - - assert.Equal(t, "Never expire", formatValidity(now, nil)) - assert.Equal(t, "Never expire", formatValidity(now, &zero)) - assert.Equal(t, "1 hour from now", formatValidity(now, &hour)) -} - func Test_runListCmd_WithSessionMaskedKeyValue(t *testing.T) { withSession(t) freezeNow(t) @@ -572,7 +545,7 @@ func Test_runListCmd_WithSessionMaskedKeyValue(t *testing.T) { }) defer srv.Close() - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts, stdout := newSessionOpts(t, srv, false) require.NoError(t, runListCmd(opts)) @@ -592,7 +565,7 @@ func Test_runListCmd_WithSessionMaskedKeyValueStructuredOutput(t *testing.T) { }) defer srv.Close() - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts, stdout := newSessionOpts(t, srv, false) format := "json" opts.PrintFlags.OutputFormat = &format @@ -605,327 +578,3 @@ func Test_runListCmd_WithSessionMaskedKeyValueStructuredOutput(t *testing.T) { assert.Equal(t, "uuid-1", keys[0]["uuid"]) assert.Equal(t, "restricted key", keys[0]["description"]) } - -func Test_runListCmd_SearchAPIMaskedKeyValue(t *testing.T) { - withoutSession(t) - freezeNow(t) - - r := httpmock.Registry{} - r.Register( - httpmock.REST("GET", "1/keys"), - httpmock.JSONResponse(search.ListApiKeysResponse{ - Keys: []search.GetApiKeyResponse{{ - Acl: []search.Acl{search.ACL_SEARCH}, - CreatedAt: 1577836800, - }}, - }), - ) - - f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewListCmd(f, func(o *ListOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runListCmd(o) - }) - out, err := test.Execute(cmd, "", out) - require.NoError(t, err) - - assert.Equal( - t, - "-\t\t[search]\t[]\tNever expire\t0\t0\t[]\t5 years ago\n", - out.String(), - ) -} - -func Test_runListCmd_SearchAPIListsLimitsAndValidity(t *testing.T) { - withoutSession(t) - freezeNow(t) - - maxHits := int32(1234) - maxQueries := int32(5678) - validity := int32(3600) - - r := httpmock.Registry{} - r.Register( - httpmock.REST("GET", "1/keys"), - httpmock.JSONResponse(search.ListApiKeysResponse{ - Keys: []search.GetApiKeyResponse{{ - Value: "foo", - Acl: []search.Acl{search.ACL_SEARCH}, - Validity: &validity, - MaxHitsPerQuery: &maxHits, - MaxQueriesPerIPPerHour: &maxQueries, - CreatedAt: 1577836800, - }}, - }), - ) - - f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewListCmd(f, func(o *ListOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runListCmd(o) - }) - out, err := test.Execute(cmd, "", out) - require.NoError(t, err) - - assert.Equal( - t, - "foo\t\t[search]\t[]\t1 hour from now\t1,234\t5,678\t[]\t5 years ago\n", - out.String(), - ) -} - -func Test_runListCmd_WithSessionListsLimits(t *testing.T) { - withSession(t) - freezeNow(t) - - maxHits := 1234 - maxQueries := 5678 - resource := sessionKey("uuid-1", "search-key", "frontend") - resource.Attributes.MaxHitsPerQuery = &maxHits - resource.Attributes.MaxQueriesPerIPPerHour = &maxQueries - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ - {resource}, - }) - defer srv.Close() - - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) - - require.NoError(t, runListCmd(opts)) - - assert.Equal( - t, - "search-key\tfrontend\t[search]\t[]\t-\t1,234\t5,678\t[]\t5 years ago\n", - stdout.String(), - ) -} - -func Test_runListCmd_WithSessionWithoutACreationDate(t *testing.T) { - withSession(t) - freezeNow(t) - - resource := sessionKey("uuid-1", "search-key", "frontend") - resource.Attributes.CreatedAt = "" - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ - {resource}, - }) - defer srv.Close() - - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) - - require.NoError(t, runListCmd(opts)) - - assert.Equal( - t, - "search-key\tfrontend\t[search]\t[]\t-\t0\t0\t[]\t-\n", - stdout.String(), - ) -} - -func Test_runListCmd_WithSessionRetriesAfterAnExpiredSession(t *testing.T) { - withSession(t) - freezeNow(t) - - requests := 0 - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { - requests++ - if requests == 1 { - assert.Equal(t, "Bearer tok-1", r.Header.Get("Authorization")) - w.WriteHeader(http.StatusUnauthorized) - return - } - - assert.Equal(t, "Bearer tok-2", r.Header.Get("Authorization")) - require.NoError(t, json.NewEncoder(w).Encode(dashboard.APIKeysResponse{ - Data: []dashboard.APIKeyResource{sessionKey("uuid-1", "search-key", "frontend")}, - Meta: dashboard.PaginationMeta{ - CurrentPage: 1, - TotalPages: 1, - TotalCount: 1, - PerPage: 15, - }, - })) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) - reauthentications := 0 - opts.Reauthenticate = func( - _ *iostreams.IOStreams, - _ *dashboard.Client, - err error, - ) (string, error) { - require.ErrorIs(t, err, dashboard.ErrSessionExpired) - reauthentications++ - - return "tok-2", nil - } - - require.NoError(t, runListCmd(opts)) - - assert.Equal(t, 1, reauthentications) - assert.Equal(t, 2, requests) - assert.Contains(t, stdout.String(), "search-key") -} - -func Test_runListCmd_WithSessionExpiredWithoutATerminal(t *testing.T) { - withSession(t) - freezeNow(t) - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - opts, stdout, stderr := newSessionOpts(t, srv, ttys{stdin: true, stderr: true}) - require.False(t, opts.IO.CanPrompt()) - - err := runListCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "requires a terminal") - assert.Contains(t, err.Error(), "algolia auth login") - assert.Empty(t, stdout.String()) - assert.Contains(t, stderr.String(), "Session expired") - - stored := auth.LoadToken() - require.NotNil(t, stored) - assert.Equal(t, "tok-1", stored.AccessToken) -} - -func Test_runListCmd_UnsupportedOutputFormatFailsBeforeListing(t *testing.T) { - t.Run("session path", func(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := httptest.NewServer( - http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - t.Errorf("no request must be made: %s %s", r.Method, r.URL.Path) - }), - ) - defer srv.Close() - - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) - format := "xml" - opts.PrintFlags.OutputFormat = &format - - err := runListCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "unable to match a printer") - assert.Empty(t, stdout.String()) - }) - - t.Run("search API path", func(t *testing.T) { - withoutSession(t) - freezeNow(t) - - r := httpmock.Registry{} - r.Register(httpmock.REST("GET", "1/keys"), func(*http.Request) (*http.Response, error) { - t.Error("no listing must be requested") - return nil, errors.New("unexpected request") - }) - - f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewListCmd(f, func(o *ListOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runListCmd(o) - }) - _, err := test.Execute(cmd, "-o xml", out) - require.Error(t, err) - assert.Contains(t, err.Error(), "unable to match a printer") - assert.Empty(t, out.String()) - }) -} - -func Test_runListCmd_WithoutASessionOrAnApplication(t *testing.T) { - withoutSession(t) - freezeNow(t) - - srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - t.Errorf("no request must be made: %s %s", r.Method, r.URL.Path) - })) - defer srv.Close() - - opts, stdout, _ := newSessionOpts(t, srv, ttys{}) - opts.Config = &test.ConfigStub{} - - err := runListCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "no application selected") - assert.Contains(t, err.Error(), "algolia application select") - assert.Empty(t, stdout.String()) -} - -func Test_runListCmd_WithoutASessionStdoutPipedStderrTTY(t *testing.T) { - withoutSession(t) - freezeNow(t) - - srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - t.Errorf("no request must be made without a session: %s %s", r.Method, r.URL.Path) - })) - defer srv.Close() - - opts, stdout, stderr := newSessionOpts(t, srv, ttys{stdin: true, stderr: true}) - require.False(t, opts.IO.CanPrompt()) - - err := runListCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "requires a terminal") - assert.Contains(t, err.Error(), "algolia auth login") - assert.Empty(t, stdout.String()) - assert.Contains(t, stderr.String(), "not logged in") - - prompting, _, _ := newSessionOpts(t, srv, ttys{stdin: true, stdout: true, stderr: true}) - assert.True(t, prompting.IO.CanPrompt()) -} - -func Test_runListCmd_SearchAPIClientErrorSurfacesTheRemediation(t *testing.T) { - tests := []struct { - name string - session bool - want string - }{ - { - name: "signed out", - want: "algolia auth login", - }, - { - name: "signed in", - session: true, - want: "algolia application select", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.session { - withSession(t) - } else { - withoutSession(t) - } - t.Setenv("ALGOLIA_API_KEY", "adm") - freezeNow(t) - - io, _, stdout, _ := iostreams.Test() - opts := &ListOptions{ - IO: io, - Config: &test.ConfigStub{}, - SearchClient: func() (*search.APIClient, error) { - return nil, config.ErrApplicationIDNotConfigured - }, - NewDashboardClient: unusedDashboardClient(t), - PrintFlags: cmdutil.NewPrintFlags(), - } - - err := runListCmd(opts) - require.Error(t, err) - assert.ErrorIs(t, err, config.ErrApplicationIDNotConfigured) - assert.Contains(t, err.Error(), tt.want) - assert.Empty(t, stdout.String()) - }) - } -} From 4ff08a6e5ef2233934f0a2c1fb4054f83ced08ae Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 14:15:55 -0700 Subject: [PATCH 5/7] test(apikeys): remove the stack's list tests entirely --- api/dashboard/client_test.go | 271 ----------------- pkg/cmd/apikeys/list/list_test.go | 485 +----------------------------- 2 files changed, 12 insertions(+), 744 deletions(-) diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index 7d5bd070..bded1e2f 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -338,277 +338,6 @@ func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "no key was returned") } -func TestListAPIKeys_FollowsPagination(t *testing.T) { - var requestedPages []string - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) - - page := r.URL.Query().Get("page") - requestedPages = append(requestedPages, page) - require.LessOrEqual(t, len(requestedPages), 3, "the pagination loop is unbounded") - - resource := APIKeyResource{ - ID: "uuid-" + page, - Type: "api_key", - Attributes: APIKeyAttributes{ - Value: "key-" + page, - ACL: []string{"search"}, - }, - } - - current := 1 - if page == "2" { - current = 2 - } - - require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ - Data: []APIKeyResource{resource}, - Meta: PaginationMeta{CurrentPage: current, TotalPages: 2, TotalCount: 2, PerPage: 1}, - })) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - keys, err := client.ListAPIKeys("test-token", "APP1") - require.NoError(t, err) - - assert.Equal(t, []string{"1", "2"}, requestedPages) - require.Len(t, keys, 2) - assert.Equal(t, "uuid-1", keys[0].UUID) - assert.Equal(t, "key-1", keys[0].Value) - assert.Equal(t, "uuid-2", keys[1].UUID) - assert.Equal(t, []string{"search"}, keys[1].ACL) -} - -func TestListAPIKeys_StopsWhenTheServerRepeatsThePage(t *testing.T) { - var requests int - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { - requests++ - require.LessOrEqual(t, requests, 3, "the pagination loop is unbounded") - - require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ - Data: []APIKeyResource{{ - ID: "uuid-1", - Type: "api_key", - Attributes: APIKeyAttributes{Value: "key-1"}, - }}, - Meta: PaginationMeta{CurrentPage: 1, TotalPages: 3, TotalCount: 3, PerPage: 1}, - })) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - keys, err := client.ListAPIKeys("test-token", "APP1") - require.NoError(t, err) - assert.Equal(t, 2, requests) - require.Len(t, keys, 1) - assert.Equal(t, "uuid-1", keys[0].UUID) -} - -func TestListAPIKeys_StopsOnAnEmptyPage(t *testing.T) { - var requests int - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { - requests++ - require.LessOrEqual(t, requests, 2, "an empty page must stop the pagination loop") - - data := []APIKeyResource{{ - ID: "uuid-1", - Type: "api_key", - Attributes: APIKeyAttributes{Value: "key-1"}, - }} - if r.URL.Query().Get("page") != "1" { - data = nil - } - - require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ - Data: data, - Meta: PaginationMeta{CurrentPage: 1, TotalPages: 10, TotalCount: 1, PerPage: 1}, - })) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - keys, err := client.ListAPIKeys("test-token", "APP1") - require.NoError(t, err) - assert.Equal(t, 2, requests) - assert.Len(t, keys, 1) -} - -func TestListAPIKeys_NoKeys(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ - Data: []APIKeyResource{}, - Meta: PaginationMeta{CurrentPage: 1, TotalPages: 0, TotalCount: 0, PerPage: 15}, - })) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - keys, err := client.ListAPIKeys("test-token", "APP1") - require.NoError(t, err) - assert.Empty(t, keys) - - marshalled, err := json.Marshal(keys) - require.NoError(t, err) - assert.Equal(t, "[]", string(marshalled)) -} - -func TestListAPIKeys_Errors(t *testing.T) { - tests := []struct { - name string - status int - body string - wantErr error - }{ - { - name: "unauthorized", - status: http.StatusUnauthorized, - wantErr: ErrSessionExpired, - }, - { - name: "unknown application", - status: http.StatusNotFound, - body: `{"errors":[{"status":"404","title":"Not Found"}]}`, - wantErr: ErrApplicationNotFound, - }, - { - name: "endpoint not routed", - status: http.StatusNotFound, - body: "The page you were looking for doesn't exist.", - wantErr: ErrEndpointNotAvailable, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc( - "/1/applications/APP1/api-keys", - func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(tt.status) - _, _ = w.Write([]byte(tt.body)) - }, - ) - - ts, client := newTestClient(mux) - defer ts.Close() - - _, err := client.ListAPIKeys("test-token", "APP1") - require.ErrorIs(t, err, tt.wantErr) - }) - } -} - -func TestCreateAPIKeyWithParams_SendsAllParamsAndReturnsTheKey(t *testing.T) { - var got CreateAPIKeyRequest - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) - - w.WriteHeader(http.StatusCreated) - require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{ - Data: APIKeyResource{ - ID: "key-uuid-123", - Type: "api_key", - Attributes: APIKeyAttributes{ - Value: "secret-key", - ApplicationID: "APP1", - ACL: []string{"search"}, - Description: "Search key", - Indexes: []string{"MOVIES"}, - Referers: []string{"https://example.com"}, - }, - }, - })) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - key, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ - ACL: []string{"search"}, - Description: "Search key", - Indexes: []string{"MOVIES"}, - Referers: []string{"https://example.com"}, - }) - require.NoError(t, err) - - assert.Equal(t, []string{"search"}, got.ACL) - assert.Equal(t, "Search key", got.Description) - assert.Equal(t, []string{"MOVIES"}, got.Indexes) - assert.Equal(t, []string{"https://example.com"}, got.Referers) - - assert.Equal(t, "key-uuid-123", key.UUID) - assert.Equal(t, "secret-key", key.Value) - assert.Equal(t, "APP1", key.ApplicationID) - assert.Equal(t, []string{"search"}, key.ACL) - assert.Equal(t, []string{"MOVIES"}, key.Indexes) -} - -func TestCreateAPIKeyWithParams_Unauthorized(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ - ACL: []string{"search"}, - Description: "Search key", - }) - require.ErrorIs(t, err, ErrSessionExpired) -} - -func TestCreateAPIKeyWithParams_ApplicationNotFound(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"errors":[{"status":"404","title":"Not Found"}]}`)) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ - ACL: []string{"search"}, - Description: "Search key", - }) - require.ErrorIs(t, err, ErrApplicationNotFound) -} - -func TestCreateAPIKeyWithParams_EndpointNotRouted(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("Not found")) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ - ACL: []string{"search"}, - }) - require.ErrorIs(t, err, ErrEndpointNotAvailable) -} - func TestRotateAPIKey_ReturnsNewValue(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc( diff --git a/pkg/cmd/apikeys/list/list_test.go b/pkg/cmd/apikeys/list/list_test.go index 02cc68c4..075ef708 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -1,37 +1,20 @@ package list import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" "testing" "time" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/zalando/go-keyring" - "github.com/algolia/cli/api/dashboard" - "github.com/algolia/cli/pkg/auth" - "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/httpmock" - "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/test" ) const unroutableAPIURL = "http://127.0.0.1:1" -func freezeNow(t *testing.T) { - t.Helper() - oldNowFn := nowFn - nowFn = func() time.Time { return time.Unix(1735689600, 0) } // 2025-01-01T00:00:00Z - t.Cleanup(func() { nowFn = oldNowFn }) -} - func withoutSession(t *testing.T) { t.Helper() t.Setenv("ALGOLIA_API_KEY", "") @@ -41,131 +24,12 @@ func withoutSession(t *testing.T) { keyring.MockInit() } -func managedKeyConfig() *test.ConfigStub { - return &test.ConfigStub{ - CurrentProfile: config.Profile{ApplicationID: "APP1"}, - ActiveAppID: "APP1", - SavedApps: map[string]test.SavedApplication{ - "APP1": {APIKeyUUID: "uuid-1", APIKey: "cli-key"}, - }, - } -} - func explicitKeyConfig() *test.ConfigStub { - cfg := managedKeyConfig() - cfg.CurrentProfile.APIKey = "adm" - - return cfg -} - -func unusedDashboardClient(t *testing.T) func(string) *dashboard.Client { - t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Errorf("unexpected dashboard request: %s %s", r.Method, r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - })) - t.Cleanup(srv.Close) - - return func(string) *dashboard.Client { - t.Error("the dashboard client must not be used on the Search API path") - c := dashboard.NewClientWithHTTPClient("test", srv.Client()) - c.APIURL = srv.URL - return c - } -} - -func withSession(t *testing.T) { - t.Helper() - withoutSession(t) - require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ - AccessToken: "tok-1", - ExpiresIn: 3600, - CreatedAt: time.Now().Unix(), - })) -} - -// listKeysServer stubs the dashboard list endpoint at wantPath, serving pages -// out of the given resource batches. -func listKeysServer( - t *testing.T, - wantPath string, - pages [][]dashboard.APIKeyResource, -) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - requests := 0 - mux.HandleFunc(wantPath, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - - requests++ - require.LessOrEqual(t, requests, len(pages)+1, "the pagination loop is unbounded") - - page := 1 - if r.URL.Query().Get("page") == "2" { - page = 2 - } - - require.NoError(t, json.NewEncoder(w).Encode(dashboard.APIKeysResponse{ - Data: pages[page-1], - Meta: dashboard.PaginationMeta{ - CurrentPage: page, - TotalPages: len(pages), - TotalCount: len(pages[page-1]), - PerPage: 15, - }, - })) - }) - return httptest.NewServer(mux) -} - -func newSessionOpts( - t *testing.T, - srv *httptest.Server, - isTTY bool, -) (*ListOptions, *bytes.Buffer) { - t.Helper() - - io, _, stdout, _ := iostreams.Test() - io.SetStdoutTTY(isTTY) - - opts := &ListOptions{ - IO: io, - Config: managedKeyConfig(), - NewDashboardClient: func(string) *dashboard.Client { - c := dashboard.NewClientWithHTTPClient("test", srv.Client()) - c.APIURL = srv.URL - return c - }, - Reauthenticate: auth.ReauthenticateIfExpired, - PrintFlags: cmdutil.NewPrintFlags(), - } - return opts, stdout -} - -func sessionKey(uuid, value, description string) dashboard.APIKeyResource { - return dashboard.APIKeyResource{ - ID: uuid, - Type: "api_key", - Attributes: dashboard.APIKeyAttributes{ - Value: value, - ACL: []string{"search"}, - Description: description, - Indexes: []string{}, - Referers: []string{}, - CreatedAt: "2020-01-01T00:00:00.000Z", - }, + return &test.ConfigStub{ + CurrentProfile: config.Profile{ApplicationID: "APP1", APIKey: "adm"}, } } -func TestNewListCmd_SkipsTheAdminACLCheck(t *testing.T) { - io, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: io} - cmd := NewListCmd(f, nil) - - assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) - assert.Empty(t, cmd.Annotations["acls"]) -} - func Test_runListCmd(t *testing.T) { tests := []struct { name string @@ -187,7 +51,10 @@ func Test_runListCmd(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withoutSession(t) - freezeNow(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{} @@ -206,10 +73,7 @@ func Test_runListCmd(t *testing.T) { ) f, out := test.NewFactory(tt.isTTY, &r, explicitKeyConfig(), "") - cmd := NewListCmd(f, func(o *ListOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runListCmd(o) - }) + cmd := NewListCmd(f, nil) out, err := test.Execute(cmd, "", out) if err != nil { t.Fatal(err) @@ -222,7 +86,10 @@ func Test_runListCmd(t *testing.T) { func Test_runListCmd_outputJSON(t *testing.T) { withoutSession(t) - freezeNow(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{} @@ -241,10 +108,7 @@ func Test_runListCmd_outputJSON(t *testing.T) { ) f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewListCmd(f, func(o *ListOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runListCmd(o) - }) + cmd := NewListCmd(f, nil) out, err := test.Execute(cmd, "--output json", out) if err != nil { t.Fatal(err) @@ -253,328 +117,3 @@ func Test_runListCmd_outputJSON(t *testing.T) { assert.Contains(t, out.String(), `"keys":[`) assert.Contains(t, out.String(), `"value":"foo"`) } - -func Test_runListCmd_ExplicitAPIKeyUsesTheSearchAPI(t *testing.T) { - withSession(t) - freezeNow(t) - - r := httpmock.Registry{} - r.Register( - httpmock.REST("GET", "1/keys"), - httpmock.JSONResponse(search.ListApiKeysResponse{ - Keys: []search.GetApiKeyResponse{{Value: "from-sapi"}}, - }), - ) - - cfg := managedKeyConfig() - cfg.CurrentProfile.APIKey = "admin-key" - - f, out := test.NewFactory(false, &r, cfg, "") - cmd := NewListCmd(f, func(o *ListOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runListCmd(o) - }) - out, err := test.Execute(cmd, "", out) - require.NoError(t, err) - - assert.Contains(t, out.String(), "from-sapi") -} - -func Test_runListCmd_EnvAPIKeyUsesTheSearchAPI(t *testing.T) { - withSession(t) - freezeNow(t) - t.Setenv("ALGOLIA_API_KEY", "env-admin-key") - - r := httpmock.Registry{} - r.Register( - httpmock.REST("GET", "1/keys"), - httpmock.JSONResponse(search.ListApiKeysResponse{ - Keys: []search.GetApiKeyResponse{{Value: "from-sapi"}}, - }), - ) - - f, out := test.NewFactory(false, &r, managedKeyConfig(), "") - cmd := NewListCmd(f, func(o *ListOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runListCmd(o) - }) - out, err := test.Execute(cmd, "", out) - require.NoError(t, err) - - assert.Contains(t, out.String(), "from-sapi") -} - -func Test_runListCmd_WithSession(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ - {sessionKey("uuid-1", "search-key", "frontend")}, - }) - defer srv.Close() - - opts, stdout := newSessionOpts(t, srv, false) - - require.NoError(t, runListCmd(opts)) - - assert.Equal( - t, - "search-key\tfrontend\t[search]\t[]\t-\t0\t0\t[]\t5 years ago\n", - stdout.String(), - ) -} - -func Test_runListCmd_WithSessionFollowsPagination(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ - {sessionKey("uuid-1", "key-1", "first")}, - {sessionKey("uuid-2", "key-2", "second")}, - }) - defer srv.Close() - - opts, stdout := newSessionOpts(t, srv, false) - - require.NoError(t, runListCmd(opts)) - - assert.Contains(t, stdout.String(), "key-1") - assert.Contains(t, stdout.String(), "key-2") -} - -func Test_runListCmd_WithSessionStructuredOutput(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ - {sessionKey("uuid-1", "search-key", "frontend")}, - }) - defer srv.Close() - - opts, stdout := newSessionOpts(t, srv, false) - format := "json" - opts.PrintFlags.OutputFormat = &format - - require.NoError(t, runListCmd(opts)) - - var keys []dashboard.APIKey - require.NoError(t, json.Unmarshal(stdout.Bytes(), &keys)) - require.Len(t, keys, 1) - assert.Equal(t, "uuid-1", keys[0].UUID) - assert.Equal(t, "search-key", keys[0].Value) - assert.Equal(t, []string{"search"}, keys[0].ACL) -} - -func Test_runListCmd_WithSessionEmpty(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) - defer srv.Close() - - opts, stdout := newSessionOpts(t, srv, false) - - require.NoError(t, runListCmd(opts)) - assert.Equal(t, "", stdout.String()) -} - -func Test_runListCmd_WithSessionUnknownApplication(t *testing.T) { - withSession(t) - freezeNow(t) - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"errors":[{"status":"404","title":"Not Found"}]}`)) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - opts, _ := newSessionOpts(t, srv, false) - - err := runListCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "APP1") - assert.Contains(t, err.Error(), "doesn't have access to it") -} - -func Test_runListCmd_SignedInWithoutAnApplication(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) - defer srv.Close() - - opts, _ := newSessionOpts(t, srv, false) - opts.Config = &test.ConfigStub{} - - err := runListCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "no application selected") - assert.Contains(t, err.Error(), "algolia application select") -} - -func Test_runListCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ - {sessionKey("uuid-1", "search-key", "frontend")}, - }) - defer srv.Close() - - opts, stdout := newSessionOpts(t, srv, false) - opts.Config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} - - require.NoError(t, runListCmd(opts)) - - assert.Contains(t, stdout.String(), "search-key") -} - -func Test_runListCmd_SearchAPIKeyWithoutADescription(t *testing.T) { - withoutSession(t) - freezeNow(t) - - r := httpmock.Registry{} - r.Register( - httpmock.REST("GET", "1/keys"), - httpmock.JSONResponse(search.ListApiKeysResponse{ - Keys: []search.GetApiKeyResponse{ - { - Value: "foo", - Acl: []search.Acl{search.ACL_SEARCH}, - CreatedAt: 1577836800, - }, - }, - }), - ) - - f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewListCmd(f, func(o *ListOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runListCmd(o) - }) - out, err := test.Execute(cmd, "", out) - require.NoError(t, err) - - assert.Equal( - t, - "foo\t\t[search]\t[]\tNever expire\t0\t0\t[]\t5 years ago\n", - out.String(), - ) -} - -func Test_runListCmd_WithSessionEndpointNotAvailable(t *testing.T) { - withSession(t) - freezeNow(t) - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("Not found")) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - opts, _ := newSessionOpts(t, srv, false) - - err := runListCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "newer Algolia API version") - assert.NotContains(t, err.Error(), "doesn't have access to it") -} - -func Test_searchAPIListError(t *testing.T) { - forbidden := &search.APIError{Status: http.StatusForbidden, Message: "Not enough rights"} - - t.Run("403 with an admin key in play", func(t *testing.T) { - withoutSession(t) - - io, _, _, _ := iostreams.Test() - cfg := managedKeyConfig() - cfg.CurrentProfile.APIKey = "weak-key" - opts := &ListOptions{IO: io, Config: cfg} - - err := searchAPIListError(opts, forbidden) - require.Error(t, err) - assert.Contains(t, err.Error(), "isn't an admin key") - assert.Contains(t, err.Error(), "ALGOLIA_API_KEY") - assert.NotContains(t, err.Error(), "algolia auth login") - }) - - t.Run("403 with the CLI-managed key", func(t *testing.T) { - withoutSession(t) - - io, _, _, _ := iostreams.Test() - opts := &ListOptions{IO: io, Config: managedKeyConfig()} - - err := searchAPIListError(opts, forbidden) - require.Error(t, err) - assert.Contains(t, err.Error(), "algolia auth login") - assert.NotContains(t, err.Error(), "--api-key") - }) - - t.Run("non-403 errors pass through", func(t *testing.T) { - withoutSession(t) - - io, _, _, _ := iostreams.Test() - opts := &ListOptions{IO: io, Config: managedKeyConfig()} - - other := &search.APIError{Status: http.StatusBadRequest, Message: "nope"} - assert.Same(t, other, searchAPIListError(opts, other)) - - plain := errors.New("boom") - assert.Same(t, plain, searchAPIListError(opts, plain)) - }) -} - -func Test_formatCreatedAt(t *testing.T) { - now := time.Unix(1735689600, 0) - - assert.Equal(t, "5 years ago", formatCreatedAt(now, "2020-01-01T00:00:00.000Z")) - assert.Equal(t, "-", formatCreatedAt(now, "")) - assert.Equal(t, "not-a-date", formatCreatedAt(now, "not-a-date")) -} - -func Test_runListCmd_WithSessionMaskedKeyValue(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ - {sessionKey("uuid-1", "", "restricted key")}, - }) - defer srv.Close() - - opts, stdout := newSessionOpts(t, srv, false) - - require.NoError(t, runListCmd(opts)) - - assert.Equal( - t, - "-\trestricted key\t[search]\t[]\t-\t0\t0\t[]\t5 years ago\n", - stdout.String(), - ) -} - -func Test_runListCmd_WithSessionMaskedKeyValueStructuredOutput(t *testing.T) { - withSession(t) - freezeNow(t) - - srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ - {sessionKey("uuid-1", "", "restricted key")}, - }) - defer srv.Close() - - opts, stdout := newSessionOpts(t, srv, false) - format := "json" - opts.PrintFlags.OutputFormat = &format - - require.NoError(t, runListCmd(opts)) - - var keys []map[string]any - require.NoError(t, json.Unmarshal(stdout.Bytes(), &keys)) - require.Len(t, keys, 1) - assert.NotContains(t, keys[0], "value") - assert.Equal(t, "uuid-1", keys[0]["uuid"]) - assert.Equal(t, "restricted key", keys[0]["description"]) -} From f6c1f9a947029aaa71e04a06607f3c2ad036c016 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 28 Jul 2026 10:37:14 -0700 Subject: [PATCH 6/7] test(apikeys): restore the list stack test suite --- api/dashboard/client_test.go | 337 ++++++++++++ pkg/cmd/apikeys/list/list_test.go | 836 +++++++++++++++++++++++++++++- 2 files changed, 1161 insertions(+), 12 deletions(-) diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index bded1e2f..ce82607b 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -338,6 +338,343 @@ func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "no key was returned") } +func TestListAPIKeys_FollowsPagination(t *testing.T) { + var requestedPages []string + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + page := r.URL.Query().Get("page") + requestedPages = append(requestedPages, page) + require.LessOrEqual(t, len(requestedPages), 3, "the pagination loop is unbounded") + + resource := APIKeyResource{ + ID: "uuid-" + page, + Type: "api_key", + Attributes: APIKeyAttributes{ + Value: "key-" + page, + ACL: []string{"search"}, + }, + } + + current := 1 + if page == "2" { + current = 2 + } + + require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ + Data: []APIKeyResource{resource}, + Meta: PaginationMeta{CurrentPage: current, TotalPages: 2, TotalCount: 2, PerPage: 1}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.NoError(t, err) + + assert.Equal(t, []string{"1", "2"}, requestedPages) + require.Len(t, keys, 2) + assert.Equal(t, "uuid-1", keys[0].UUID) + assert.Equal(t, "key-1", keys[0].Value) + assert.Equal(t, "uuid-2", keys[1].UUID) + assert.Equal(t, []string{"search"}, keys[1].ACL) +} + +func TestListAPIKeys_StopsWhenTheServerRepeatsThePage(t *testing.T) { + var requests int + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + requests++ + require.LessOrEqual(t, requests, 3, "the pagination loop is unbounded") + + require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ + Data: []APIKeyResource{{ + ID: "uuid-1", + Type: "api_key", + Attributes: APIKeyAttributes{Value: "key-1"}, + }}, + Meta: PaginationMeta{CurrentPage: 1, TotalPages: 3, TotalCount: 3, PerPage: 1}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.NoError(t, err) + assert.Equal(t, 2, requests) + require.Len(t, keys, 1) + assert.Equal(t, "uuid-1", keys[0].UUID) +} + +func TestListAPIKeys_ErrorsWhenAPageHasNoPaginationMetadata(t *testing.T) { + var requests int + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + requests++ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "data": []APIKeyResource{{ + ID: "uuid-1", + Type: "api_key", + Attributes: APIKeyAttributes{Value: "key-1"}, + }}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.Error(t, err) + assert.Contains(t, err.Error(), "without pagination metadata") + assert.Nil(t, keys) + assert.Equal(t, 1, requests) +} + +func TestListAPIKeys_StopsOnAnEmptyPage(t *testing.T) { + var requests int + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + requests++ + require.LessOrEqual(t, requests, 2, "an empty page must stop the pagination loop") + + data := []APIKeyResource{{ + ID: "uuid-1", + Type: "api_key", + Attributes: APIKeyAttributes{Value: "key-1"}, + }} + if r.URL.Query().Get("page") != "1" { + data = nil + } + + require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ + Data: data, + Meta: PaginationMeta{CurrentPage: 1, TotalPages: 10, TotalCount: 1, PerPage: 1}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.NoError(t, err) + assert.Equal(t, 2, requests) + assert.Len(t, keys, 1) +} + +func TestListAPIKeys_NoKeys(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{ + Data: []APIKeyResource{}, + Meta: PaginationMeta{CurrentPage: 1, TotalPages: 0, TotalCount: 0, PerPage: 15}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + keys, err := client.ListAPIKeys("test-token", "APP1") + require.NoError(t, err) + assert.Empty(t, keys) + + marshalled, err := json.Marshal(keys) + require.NoError(t, err) + assert.Equal(t, "[]", string(marshalled)) +} + +func TestListAPIKeys_Errors(t *testing.T) { + tests := []struct { + name string + status int + body string + wantErr error + }{ + { + name: "unauthorized", + status: http.StatusUnauthorized, + wantErr: ErrSessionExpired, + }, + { + name: "unknown application", + status: http.StatusNotFound, + body: `{"errors":[{"status":"404","title":"Not Found"}]}`, + wantErr: ErrApplicationNotFound, + }, + { + name: "endpoint not routed", + status: http.StatusNotFound, + body: "The page you were looking for doesn't exist.", + wantErr: ErrEndpointNotAvailable, + }, + { + name: "empty body", + status: http.StatusNotFound, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "empty JSON object", + status: http.StatusNotFound, + body: `{}`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "JSON null", + status: http.StatusNotFound, + body: `null`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "JSON number", + status: http.StatusNotFound, + body: `123`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "JSON string", + status: http.StatusNotFound, + body: `"Not Found"`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "Rails unrouted path", + status: http.StatusNotFound, + body: `{"status":404,"error":"Not Found"}`, + wantErr: ErrEndpointNotAvailable, + }, + { + name: "empty JSON:API errors array", + status: http.StatusNotFound, + body: `{"errors":[]}`, + wantErr: ErrEndpointNotAvailable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc( + "/1/applications/APP1/api-keys", + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + }, + ) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.ListAPIKeys("test-token", "APP1") + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestCreateAPIKeyWithParams_SendsAllParamsAndReturnsTheKey(t *testing.T) { + var got CreateAPIKeyRequest + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{ + Data: APIKeyResource{ + ID: "key-uuid-123", + Type: "api_key", + Attributes: APIKeyAttributes{ + Value: "secret-key", + ApplicationID: "APP1", + ACL: []string{"search"}, + Description: "Search key", + Indexes: []string{"MOVIES"}, + Referers: []string{"https://example.com"}, + }, + }, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + key, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + Indexes: []string{"MOVIES"}, + Referers: []string{"https://example.com"}, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"search"}, got.ACL) + assert.Equal(t, "Search key", got.Description) + assert.Equal(t, []string{"MOVIES"}, got.Indexes) + assert.Equal(t, []string{"https://example.com"}, got.Referers) + + assert.Equal(t, "key-uuid-123", key.UUID) + assert.Equal(t, "secret-key", key.Value) + assert.Equal(t, "APP1", key.ApplicationID) + assert.Equal(t, []string{"search"}, key.ACL) + assert.Equal(t, []string{"MOVIES"}, key.Indexes) +} + +func TestCreateAPIKeyWithParams_Unauthorized(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + }) + require.ErrorIs(t, err, ErrSessionExpired) +} + +func TestCreateAPIKeyWithParams_ApplicationNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errors":[{"status":"404","title":"Not Found"}]}`)) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + }) + require.ErrorIs(t, err, ErrApplicationNotFound) +} + +func TestCreateAPIKeyWithParams_EndpointNotRouted(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Not found")) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + }) + require.ErrorIs(t, err, ErrEndpointNotAvailable) +} + func TestRotateAPIKey_ReturnsNewValue(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc( diff --git a/pkg/cmd/apikeys/list/list_test.go b/pkg/cmd/apikeys/list/list_test.go index 075ef708..ebc1c36a 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -1,20 +1,43 @@ package list import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" "testing" "time" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/zalando/go-keyring" + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/test" ) const unroutableAPIURL = "http://127.0.0.1:1" +type ttys struct { + stdin bool + stdout bool + stderr bool +} + +func freezeNow(t *testing.T) { + t.Helper() + oldNowFn := nowFn + nowFn = func() time.Time { return time.Unix(1735689600, 0) } // 2025-01-01T00:00:00Z + t.Cleanup(func() { nowFn = oldNowFn }) +} + func withoutSession(t *testing.T) { t.Helper() t.Setenv("ALGOLIA_API_KEY", "") @@ -24,12 +47,133 @@ func withoutSession(t *testing.T) { keyring.MockInit() } -func explicitKeyConfig() *test.ConfigStub { +func managedKeyConfig() *test.ConfigStub { return &test.ConfigStub{ - CurrentProfile: config.Profile{ApplicationID: "APP1", APIKey: "adm"}, + CurrentProfile: config.Profile{ApplicationID: "APP1"}, + ActiveAppID: "APP1", + SavedApps: map[string]test.SavedApplication{ + "APP1": {APIKeyUUID: "uuid-1", APIKey: "cli-key"}, + }, } } +func explicitKeyConfig() *test.ConfigStub { + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "adm" + + return cfg +} + +func unusedDashboardClient(t *testing.T) func(string) *dashboard.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected dashboard request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + return func(string) *dashboard.Client { + t.Error("the dashboard client must not be used on the Search API path") + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + } +} + +func withSession(t *testing.T) { + t.Helper() + withoutSession(t) + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "tok-1", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) +} + +// listKeysServer stubs the dashboard list endpoint at wantPath, serving pages +// out of the given resource batches. +func listKeysServer( + t *testing.T, + wantPath string, + pages [][]dashboard.APIKeyResource, +) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + requests := 0 + mux.HandleFunc(wantPath, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + + requests++ + require.LessOrEqual(t, requests, len(pages)+1, "the pagination loop is unbounded") + + page := 1 + if r.URL.Query().Get("page") == "2" { + page = 2 + } + + require.NoError(t, json.NewEncoder(w).Encode(dashboard.APIKeysResponse{ + Data: pages[page-1], + Meta: dashboard.PaginationMeta{ + CurrentPage: page, + TotalPages: len(pages), + TotalCount: len(pages[page-1]), + PerPage: 15, + }, + })) + }) + return httptest.NewServer(mux) +} + +func newSessionOpts( + t *testing.T, + srv *httptest.Server, + tty ttys, +) (*ListOptions, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(tty.stdin) + io.SetStdoutTTY(tty.stdout) + io.SetStderrTTY(tty.stderr) + + opts := &ListOptions{ + IO: io, + Config: managedKeyConfig(), + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + }, + Reauthenticate: auth.ReauthenticateIfExpired, + PrintFlags: cmdutil.NewPrintFlags(), + } + return opts, stdout, stderr +} + +func sessionKey(uuid, value, description string) dashboard.APIKeyResource { + return dashboard.APIKeyResource{ + ID: uuid, + Type: "api_key", + Attributes: dashboard.APIKeyAttributes{ + Value: value, + ACL: []string{"search"}, + Description: description, + Indexes: []string{}, + Referers: []string{}, + CreatedAt: "2020-01-01T00:00:00.000Z", + }, + } +} + +func TestNewListCmd_SkipsTheAdminACLCheck(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: io} + cmd := NewListCmd(f, nil) + + assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) + assert.Empty(t, cmd.Annotations["acls"]) +} + func Test_runListCmd(t *testing.T) { tests := []struct { name string @@ -51,10 +195,7 @@ func Test_runListCmd(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withoutSession(t) - - oldNowFn := nowFn - nowFn = func() time.Time { return time.Unix(1735689600, 0) } // 2025-01-01T00:00:00Z - t.Cleanup(func() { nowFn = oldNowFn }) + freezeNow(t) name := "test" r := httpmock.Registry{} @@ -73,7 +214,10 @@ func Test_runListCmd(t *testing.T) { ) f, out := test.NewFactory(tt.isTTY, &r, explicitKeyConfig(), "") - cmd := NewListCmd(f, nil) + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) out, err := test.Execute(cmd, "", out) if err != nil { t.Fatal(err) @@ -86,10 +230,7 @@ func Test_runListCmd(t *testing.T) { func Test_runListCmd_outputJSON(t *testing.T) { withoutSession(t) - - oldNowFn := nowFn - nowFn = func() time.Time { return time.Unix(1735689600, 0) } // 2025-01-01T00:00:00Z - t.Cleanup(func() { nowFn = oldNowFn }) + freezeNow(t) name := "test" r := httpmock.Registry{} @@ -108,7 +249,10 @@ func Test_runListCmd_outputJSON(t *testing.T) { ) f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewListCmd(f, nil) + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) out, err := test.Execute(cmd, "--output json", out) if err != nil { t.Fatal(err) @@ -117,3 +261,671 @@ func Test_runListCmd_outputJSON(t *testing.T) { assert.Contains(t, out.String(), `"keys":[`) assert.Contains(t, out.String(), `"value":"foo"`) } + +func Test_runListCmd_ExplicitAPIKeyUsesTheSearchAPI(t *testing.T) { + withSession(t) + freezeNow(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{{Value: "from-sapi"}}, + }), + ) + + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "admin-key" + + f, out := test.NewFactory(false, &r, cfg, "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Contains(t, out.String(), "from-sapi") +} + +func Test_runListCmd_EnvAPIKeyUsesTheSearchAPI(t *testing.T) { + withSession(t) + freezeNow(t) + t.Setenv("ALGOLIA_API_KEY", "env-admin-key") + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{{Value: "from-sapi"}}, + }), + ) + + f, out := test.NewFactory(false, &r, managedKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Contains(t, out.String(), "from-sapi") +} + +func Test_runListCmd_WithSession(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "search-key", "frontend")}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + + assert.Equal( + t, + "search-key\tfrontend\t[search]\t[]\t-\t0\t0\t[]\t5 years ago\n", + stdout.String(), + ) +} + +func Test_runListCmd_WithSessionFollowsPagination(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "key-1", "first")}, + {sessionKey("uuid-2", "key-2", "second")}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + + assert.Contains(t, stdout.String(), "key-1") + assert.Contains(t, stdout.String(), "key-2") +} + +func Test_runListCmd_WithSessionStructuredOutput(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "search-key", "frontend")}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + format := "json" + opts.PrintFlags.OutputFormat = &format + + require.NoError(t, runListCmd(opts)) + + var keys []dashboard.APIKey + require.NoError(t, json.Unmarshal(stdout.Bytes(), &keys)) + require.Len(t, keys, 1) + assert.Equal(t, "uuid-1", keys[0].UUID) + assert.Equal(t, "search-key", keys[0].Value) + assert.Equal(t, []string{"search"}, keys[0].ACL) +} + +func Test_runListCmd_WithSessionEmpty(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + assert.Equal(t, "", stdout.String()) +} + +func Test_runListCmd_WithSessionUnknownApplication(t *testing.T) { + withSession(t) + freezeNow(t) + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errors":[{"status":"404","title":"Not Found"}]}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + opts, _, _ := newSessionOpts(t, srv, ttys{}) + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "APP1") + assert.Contains(t, err.Error(), "doesn't have access to it") +} + +func Test_runListCmd_SignedInWithoutAnApplication(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{{}}) + defer srv.Close() + + opts, _, _ := newSessionOpts(t, srv, ttys{}) + opts.Config = &test.ConfigStub{} + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no application selected") + assert.Contains(t, err.Error(), "algolia application select") +} + +func Test_runListCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "search-key", "frontend")}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts.Config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} + + require.NoError(t, runListCmd(opts)) + + assert.Contains(t, stdout.String(), "search-key") +} + +func Test_runListCmd_SearchAPIKeyWithoutADescription(t *testing.T) { + withoutSession(t) + freezeNow(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{ + { + Value: "foo", + Acl: []search.Acl{search.ACL_SEARCH}, + CreatedAt: 1577836800, + }, + }, + }), + ) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Equal( + t, + "foo\t\t[search]\t[]\tNever expire\t0\t0\t[]\t5 years ago\n", + out.String(), + ) +} + +func Test_runListCmd_WithSessionEndpointNotAvailable(t *testing.T) { + withSession(t) + freezeNow(t) + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Not found")) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + opts, _, _ := newSessionOpts(t, srv, ttys{}) + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "newer Algolia API version") + assert.NotContains(t, err.Error(), "doesn't have access to it") +} + +func Test_searchAPIListError(t *testing.T) { + forbidden := &search.APIError{Status: http.StatusForbidden, Message: "Not enough rights"} + + t.Run("403 with an admin key in play", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "weak-key" + opts := &ListOptions{IO: io, Config: cfg} + + err := searchAPIListError(opts, forbidden) + require.Error(t, err) + assert.Contains(t, err.Error(), "isn't an admin key") + assert.Contains(t, err.Error(), "ALGOLIA_API_KEY") + assert.NotContains(t, err.Error(), "algolia auth login") + }) + + t.Run("403 with the CLI-managed key", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + opts := &ListOptions{IO: io, Config: managedKeyConfig()} + + err := searchAPIListError(opts, forbidden) + require.Error(t, err) + assert.Contains(t, err.Error(), "algolia auth login") + assert.NotContains(t, err.Error(), "--api-key") + }) + + t.Run("non-403 errors pass through", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + opts := &ListOptions{IO: io, Config: managedKeyConfig()} + + other := &search.APIError{Status: http.StatusBadRequest, Message: "nope"} + assert.Same(t, other, searchAPIListError(opts, other)) + + plain := errors.New("boom") + assert.Same(t, plain, searchAPIListError(opts, plain)) + }) +} + +func Test_formatCreatedAt(t *testing.T) { + now := time.Unix(1735689600, 0) + + assert.Equal(t, "5 years ago", formatCreatedAt(now, "2020-01-01T00:00:00.000Z")) + assert.Equal(t, "-", formatCreatedAt(now, "")) + assert.Equal(t, "not-a-date", formatCreatedAt(now, "not-a-date")) +} + +func Test_formatLimit(t *testing.T) { + zero := 0 + large := 1234567 + + assert.Equal(t, "0", formatLimit(nil)) + assert.Equal(t, "0", formatLimit(&zero)) + assert.Equal(t, "1,234,567", formatLimit(&large)) +} + +func Test_formatValidity(t *testing.T) { + now := time.Unix(1735689600, 0) + zero := int32(0) + hour := int32(3600) + + assert.Equal(t, "Never expire", formatValidity(now, nil)) + assert.Equal(t, "Never expire", formatValidity(now, &zero)) + assert.Equal(t, "1 hour from now", formatValidity(now, &hour)) +} + +func Test_runListCmd_WithSessionMaskedKeyValue(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "", "restricted key")}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + + assert.Equal( + t, + "-\trestricted key\t[search]\t[]\t-\t0\t0\t[]\t5 years ago\n", + stdout.String(), + ) +} + +func Test_runListCmd_WithSessionMaskedKeyValueStructuredOutput(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {sessionKey("uuid-1", "", "restricted key")}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + format := "json" + opts.PrintFlags.OutputFormat = &format + + require.NoError(t, runListCmd(opts)) + + var keys []map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &keys)) + require.Len(t, keys, 1) + assert.NotContains(t, keys[0], "value") + assert.Equal(t, "uuid-1", keys[0]["uuid"]) + assert.Equal(t, "restricted key", keys[0]["description"]) +} + +func Test_runListCmd_SearchAPIMaskedKeyValue(t *testing.T) { + withoutSession(t) + freezeNow(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{{ + Acl: []search.Acl{search.ACL_SEARCH}, + CreatedAt: 1577836800, + }}, + }), + ) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Equal( + t, + "-\t\t[search]\t[]\tNever expire\t0\t0\t[]\t5 years ago\n", + out.String(), + ) +} + +func Test_runListCmd_SearchAPIListsLimitsAndValidity(t *testing.T) { + withoutSession(t) + freezeNow(t) + + maxHits := int32(1234) + maxQueries := int32(5678) + validity := int32(3600) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/keys"), + httpmock.JSONResponse(search.ListApiKeysResponse{ + Keys: []search.GetApiKeyResponse{{ + Value: "foo", + Acl: []search.Acl{search.ACL_SEARCH}, + Validity: &validity, + MaxHitsPerQuery: &maxHits, + MaxQueriesPerIPPerHour: &maxQueries, + CreatedAt: 1577836800, + }}, + }), + ) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + out, err := test.Execute(cmd, "", out) + require.NoError(t, err) + + assert.Equal( + t, + "foo\t\t[search]\t[]\t1 hour from now\t1,234\t5,678\t[]\t5 years ago\n", + out.String(), + ) +} + +func Test_runListCmd_WithSessionListsLimits(t *testing.T) { + withSession(t) + freezeNow(t) + + maxHits := 1234 + maxQueries := 5678 + resource := sessionKey("uuid-1", "search-key", "frontend") + resource.Attributes.MaxHitsPerQuery = &maxHits + resource.Attributes.MaxQueriesPerIPPerHour = &maxQueries + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {resource}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + + assert.Equal( + t, + "search-key\tfrontend\t[search]\t[]\t-\t1,234\t5,678\t[]\t5 years ago\n", + stdout.String(), + ) +} + +func Test_runListCmd_WithSessionWithoutACreationDate(t *testing.T) { + withSession(t) + freezeNow(t) + + resource := sessionKey("uuid-1", "search-key", "frontend") + resource.Attributes.CreatedAt = "" + + srv := listKeysServer(t, "/1/applications/APP1/api-keys", [][]dashboard.APIKeyResource{ + {resource}, + }) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + + require.NoError(t, runListCmd(opts)) + + assert.Equal( + t, + "search-key\tfrontend\t[search]\t[]\t-\t0\t0\t[]\t-\n", + stdout.String(), + ) +} + +func Test_runListCmd_WithSessionRetriesAfterAnExpiredSession(t *testing.T) { + withSession(t) + freezeNow(t) + + requests := 0 + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests == 1 { + assert.Equal(t, "Bearer tok-1", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusUnauthorized) + return + } + + assert.Equal(t, "Bearer tok-2", r.Header.Get("Authorization")) + require.NoError(t, json.NewEncoder(w).Encode(dashboard.APIKeysResponse{ + Data: []dashboard.APIKeyResource{sessionKey("uuid-1", "search-key", "frontend")}, + Meta: dashboard.PaginationMeta{ + CurrentPage: 1, + TotalPages: 1, + TotalCount: 1, + PerPage: 15, + }, + })) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + reauthentications := 0 + opts.Reauthenticate = func( + _ *iostreams.IOStreams, + _ *dashboard.Client, + err error, + ) (string, error) { + require.ErrorIs(t, err, dashboard.ErrSessionExpired) + reauthentications++ + + return "tok-2", nil + } + + require.NoError(t, runListCmd(opts)) + + assert.Equal(t, 1, reauthentications) + assert.Equal(t, 2, requests) + assert.Contains(t, stdout.String(), "search-key") +} + +func Test_runListCmd_WithSessionExpiredWithoutATerminal(t *testing.T) { + withSession(t) + freezeNow(t) + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + opts, stdout, stderr := newSessionOpts(t, srv, ttys{stdin: true, stderr: true}) + require.False(t, opts.IO.CanPrompt()) + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") + assert.Contains(t, err.Error(), "algolia auth login") + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "Session expired") + + stored := auth.LoadToken() + require.NotNil(t, stored) + assert.Equal(t, "tok-1", stored.AccessToken) +} + +func Test_runListCmd_UnsupportedOutputFormatFailsBeforeListing(t *testing.T) { + t.Run("session path", func(t *testing.T) { + withSession(t) + freezeNow(t) + + srv := httptest.NewServer( + http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("no request must be made: %s %s", r.Method, r.URL.Path) + }), + ) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + format := "xml" + opts.PrintFlags.OutputFormat = &format + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to match a printer") + assert.Empty(t, stdout.String()) + }) + + t.Run("search API path", func(t *testing.T) { + withoutSession(t) + freezeNow(t) + + r := httpmock.Registry{} + r.Register(httpmock.REST("GET", "1/keys"), func(*http.Request) (*http.Response, error) { + t.Error("no listing must be requested") + return nil, errors.New("unexpected request") + }) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewListCmd(f, func(o *ListOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runListCmd(o) + }) + _, err := test.Execute(cmd, "-o xml", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to match a printer") + assert.Empty(t, out.String()) + }) +} + +func Test_runListCmd_WithoutASessionOrAnApplication(t *testing.T) { + withoutSession(t) + freezeNow(t) + + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("no request must be made: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + opts, stdout, _ := newSessionOpts(t, srv, ttys{}) + opts.Config = &test.ConfigStub{} + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no application selected") + assert.Contains(t, err.Error(), "algolia application select") + assert.Empty(t, stdout.String()) +} + +func Test_runListCmd_WithoutASessionStdoutPipedStderrTTY(t *testing.T) { + withoutSession(t) + freezeNow(t) + + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("no request must be made without a session: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + opts, stdout, stderr := newSessionOpts(t, srv, ttys{stdin: true, stderr: true}) + require.False(t, opts.IO.CanPrompt()) + + err := runListCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") + assert.Contains(t, err.Error(), "algolia auth login") + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "not logged in") + + prompting, _, _ := newSessionOpts(t, srv, ttys{stdin: true, stdout: true, stderr: true}) + assert.True(t, prompting.IO.CanPrompt()) +} + +func Test_runListCmd_SearchAPIClientErrorSurfacesTheRemediation(t *testing.T) { + tests := []struct { + name string + session bool + want string + }{ + { + name: "signed out", + want: "algolia auth login", + }, + { + name: "signed in", + session: true, + want: "algolia application select", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.session { + withSession(t) + } else { + withoutSession(t) + } + t.Setenv("ALGOLIA_API_KEY", "adm") + freezeNow(t) + + io, _, stdout, _ := iostreams.Test() + opts := &ListOptions{ + IO: io, + Config: &test.ConfigStub{}, + SearchClient: func() (*search.APIClient, error) { + return nil, config.ErrApplicationIDNotConfigured + }, + NewDashboardClient: unusedDashboardClient(t), + PrintFlags: cmdutil.NewPrintFlags(), + } + + err := runListCmd(opts) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrApplicationIDNotConfigured) + assert.Contains(t, err.Error(), tt.want) + assert.Empty(t, stdout.String()) + }) + } +} From f22247b906c9ca72f1f569e979c7ae6bf7b5097d Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 28 Jul 2026 10:37:53 -0700 Subject: [PATCH 7/7] fix(apikeys): drop the unreachable list 403 branch --- pkg/cmd/apikeys/list/list.go | 7 ------- pkg/cmd/apikeys/list/list_test.go | 5 +++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/apikeys/list/list.go b/pkg/cmd/apikeys/list/list.go index 88d8e611..e85c5429 100644 --- a/pkg/cmd/apikeys/list/list.go +++ b/pkg/cmd/apikeys/list/list.go @@ -337,13 +337,6 @@ func searchAPIListError(opts *ListOptions, err error) error { } cs := opts.IO.ColorScheme() - if config.ShouldUseSessionAPIKey(opts.Config) { - return fmt.Errorf( - "%w\nRun %s to list API keys without an admin key", - err, - cs.Bold("algolia auth login"), - ) - } return fmt.Errorf( "%w\nThe API key in use isn't an admin key. Provide an admin key, or drop the key set through %s, %s or your profile to list the keys the CLI created for your signed-in session", diff --git a/pkg/cmd/apikeys/list/list_test.go b/pkg/cmd/apikeys/list/list_test.go index ebc1c36a..f8ce8a47 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -518,8 +518,9 @@ func Test_searchAPIListError(t *testing.T) { err := searchAPIListError(opts, forbidden) require.Error(t, err) - assert.Contains(t, err.Error(), "algolia auth login") - assert.NotContains(t, err.Error(), "--api-key") + assert.Contains(t, err.Error(), "isn't an admin key") + assert.Contains(t, err.Error(), "ALGOLIA_API_KEY") + assert.NotContains(t, err.Error(), "algolia auth login") }) t.Run("non-403 errors pass through", func(t *testing.T) {