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())
+ })
+ }
+}