diff --git a/api/dashboard/client.go b/api/dashboard/client.go index a26cb899..c8a3e95d 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -557,6 +557,98 @@ 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 + } + + 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 page >= keysResp.Meta.TotalPages { + return allKeys, nil + } + } +} + +func notFoundError(body io.Reader) error { + raw, err := io.ReadAll(body) + 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 + } + + 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 +676,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..ce82607b 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -338,6 +338,245 @@ 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 @@ -420,6 +659,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..e85c5429 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 + Reauthenticate func(*iostreams.IOStreams, *dashboard.Client, error) (string, error) PrintFlags *cmdutil.PrintFlags } @@ -34,16 +53,44 @@ 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) + }, + Reauthenticate: auth.ReauthenticateIfExpired, + 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. + + 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 + $ 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,40 +107,131 @@ func NewListCmd(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman // runListCmd executes the list command func runListCmd(opts *ListOptions) error { - client, err := opts.SearchClient() + if !config.ShouldUseSessionAPIKey(opts.Config) { + return runListWithSearchAPI(opts) + } + + return runListWithSessionAPI(opts) +} + +func structuredPrinter(opts *ListOptions) (printers.Printer, error) { + if !opts.PrintFlags.HasStructuredOutput() { + return nil, nil + } + + 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( + "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 printer != nil { + return printer.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") - res, err := client.ListApiKeys() + keys, err := client.ListAPIKeys(accessToken, appID) opts.IO.StopProgressIndicator() + if err == nil { + return keys, nil + } + + accessToken, err = opts.Reauthenticate(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 { + printer, err := structuredPrinter(opts) if err != nil { return err } - if opts.PrintFlags.HasStructuredOutput() { - p, err := opts.PrintFlags.ToPrinter() - if err != nil { - return err - } - return p.Print(opts.IO, res) + client, err := opts.SearchClient() + if err != nil { + return auth.WithRemediation(err) } - 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() + now := nowFn() + + opts.IO.StartProgressIndicatorWithLabel("Fetching API Keys") + res, err := client.ListApiKeys() + opts.IO.StopProgressIndicator() + if err != nil { + return searchAPIListError(opts, err) + } + + if printer != nil { + return printer.Print(opts.IO, res) } // Sort API Keys by createdAt @@ -101,35 +239,109 @@ func runListCmd(opts *ListOptions) error { 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{ + formatKeyValue(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() + + 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..f8ce8a47 100644 --- a/pkg/cmd/apikeys/list/list_test.go +++ b/pkg/cmd/apikeys/list/list_test.go @@ -1,16 +1,179 @@ 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", "") + t.Setenv("ALGOLIA_ADMIN_API_KEY", "") + t.Setenv("ALGOLIA_APPLICATION_ID", "") + t.Setenv("ALGOLIA_API_URL", unroutableAPIURL) + 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, + 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 @@ -31,9 +194,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{} @@ -51,8 +213,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) @@ -64,9 +229,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{} @@ -84,8 +248,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) @@ -94,3 +261,672 @@ 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(), "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) { + 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()) + }) + } +}