From 557d2e4318f52891740f16689830da8fde316ec9 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 12:37:52 -0700 Subject: [PATCH 1/7] feat(apikeys): create keys with the signed-in session --- api/dashboard/client.go | 43 ++- api/dashboard/client_test.go | 82 ++++++ api/dashboard/types.go | 42 ++- pkg/auth/authenticate.go | 18 +- pkg/auth/authenticate_test.go | 52 ++++ pkg/cmd/apikeys/create/create.go | 184 +++++++++++- pkg/cmd/apikeys/create/create_test.go | 407 +++++++++++++++++++++++++- pkg/config/profile.go | 19 ++ pkg/config/resolution_test.go | 120 ++++++++ 9 files changed, 940 insertions(+), 27 deletions(-) create mode 100644 pkg/auth/authenticate_test.go diff --git a/api/dashboard/client.go b/api/dashboard/client.go index 89cdd8e2..a26cb899 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -545,33 +545,55 @@ func (c *Client) CreateAPIKey( acl []string, description string, ) (CreatedAPIKey, error) { - payload := CreateAPIKeyRequest{ACL: acl, Description: description} - body, err := json.Marshal(payload) + key, err := c.CreateAPIKeyWithParams( + accessToken, + appID, + CreateAPIKeyRequest{ACL: acl, Description: description}, + ) if err != nil { return CreatedAPIKey{}, err } + return CreatedAPIKey{Value: key.Value, UUID: key.UUID}, nil +} + +func (c *Client) CreateAPIKeyWithParams( + accessToken, appID string, + params CreateAPIKeyRequest, +) (APIKey, error) { + body, err := json.Marshal(params) + if err != nil { + return APIKey{}, err + } + endpoint := fmt.Sprintf("%s/1/applications/%s/api-keys", c.APIURL, url.PathEscape(appID)) req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { - return CreatedAPIKey{}, err + return APIKey{}, err } c.setAPIHeaders(req, accessToken) req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { - return CreatedAPIKey{}, fmt.Errorf("create API key request failed: %w", err) + return APIKey{}, fmt.Errorf("create API key request failed: %w", err) } defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return APIKey{}, ErrSessionExpired + } + if resp.StatusCode == http.StatusNotFound { + return APIKey{}, ErrApplicationNotFound + } + respBody, err := io.ReadAll(resp.Body) if err != nil { - return CreatedAPIKey{}, fmt.Errorf("failed to read API key response: %w", err) + return APIKey{}, fmt.Errorf("failed to read API key response: %w", err) } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return CreatedAPIKey{}, fmt.Errorf( + return APIKey{}, fmt.Errorf( "create API key failed with status %d: %s", resp.StatusCode, string(respBody), @@ -580,22 +602,21 @@ func (c *Client) CreateAPIKey( var keyResp CreateAPIKeyResponse if err := json.Unmarshal(respBody, &keyResp); err != nil { - return CreatedAPIKey{}, fmt.Errorf( + return APIKey{}, fmt.Errorf( "failed to parse API key response: %w (body: %s)", err, string(respBody), ) } - key := keyResp.Data.Attributes.Value - if key == "" { - return CreatedAPIKey{}, fmt.Errorf( + if keyResp.Data.Attributes.Value == "" { + return APIKey{}, fmt.Errorf( "API key creation succeeded but no key was returned in the response: %s", string(respBody), ) } - return CreatedAPIKey{Value: key, UUID: keyResp.Data.ID}, nil + return keyResp.Data.toAPIKey(), nil } // RotateAPIKey regenerates the secret value of the API key identified by keyUUID diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index bded1e2f..b3315cfe 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -338,6 +338,88 @@ func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "no key was returned") } +func TestCreateAPIKeyWithParams_SendsAllParamsAndReturnsTheKey(t *testing.T) { + var got CreateAPIKeyRequest + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{ + Data: APIKeyResource{ + ID: "key-uuid-123", + Type: "api_key", + Attributes: APIKeyAttributes{ + Value: "secret-key", + ApplicationID: "APP1", + ACL: []string{"search"}, + Description: "Search key", + Indexes: []string{"MOVIES"}, + Referers: []string{"https://example.com"}, + }, + }, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + key, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + Indexes: []string{"MOVIES"}, + Referers: []string{"https://example.com"}, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"search"}, got.ACL) + assert.Equal(t, "Search key", got.Description) + assert.Equal(t, []string{"MOVIES"}, got.Indexes) + assert.Equal(t, []string{"https://example.com"}, got.Referers) + + assert.Equal(t, "key-uuid-123", key.UUID) + assert.Equal(t, "secret-key", key.Value) + assert.Equal(t, "APP1", key.ApplicationID) + assert.Equal(t, []string{"search"}, key.ACL) + assert.Equal(t, []string{"MOVIES"}, key.Indexes) +} + +func TestCreateAPIKeyWithParams_Unauthorized(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + }) + require.ErrorIs(t, err, ErrSessionExpired) +} + +func TestCreateAPIKeyWithParams_ApplicationNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errors":[{"status":"404","title":"Not Found"}]}`)) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + }) + require.ErrorIs(t, err, ErrApplicationNotFound) +} + func TestRotateAPIKey_ReturnsNewValue(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc( diff --git a/api/dashboard/types.go b/api/dashboard/types.go index ecf45fdd..3f4ae54c 100644 --- a/api/dashboard/types.go +++ b/api/dashboard/types.go @@ -107,6 +107,8 @@ type RegionsResponse struct { // ErrSessionExpired is returned when an API call gets a 401 Unauthorized. var ErrSessionExpired = errors.New("session expired") +var ErrApplicationNotFound = errors.New("application not found") + // ErrClusterUnavailable is returned when a region has no available cluster. type ErrClusterUnavailable struct { Region string @@ -127,6 +129,8 @@ type OAuthErrorResponse struct { type CreateAPIKeyRequest struct { ACL []string `json:"acl"` Description string `json:"description"` + Indexes []string `json:"indexes,omitempty"` + Referers []string `json:"referers,omitempty"` } // APIKeyResource is a JSON:API resource wrapper for an API key. @@ -138,7 +142,28 @@ type APIKeyResource struct { // APIKeyAttributes contains the actual API key fields. type APIKeyAttributes struct { - Value string `json:"value"` + Value string `json:"value"` + ApplicationID string `json:"application_id"` + ACL []string `json:"acl"` + Description string `json:"description"` + Indexes []string `json:"indexes"` + Referers []string `json:"referers"` + MaxHitsPerQuery *int `json:"max_hits_per_query"` + MaxQueriesPerIPPerHour *int `json:"max_queries_per_ip_per_hour"` + QueryParameters *string `json:"query_parameters"` +} + +type APIKey struct { + UUID string `json:"uuid"` + ApplicationID string `json:"application_id,omitempty"` + Value string `json:"value,omitempty"` + Description string `json:"description,omitempty"` + ACL []string `json:"acl,omitempty"` + Indexes []string `json:"indexes,omitempty"` + Referers []string `json:"referers,omitempty"` + MaxHitsPerQuery *int `json:"max_hits_per_query,omitempty"` + MaxQueriesPerIPPerHour *int `json:"max_queries_per_ip_per_hour,omitempty"` + QueryParameters *string `json:"query_parameters,omitempty"` } // CreateAPIKeyResponse is the JSON:API response from POST /1/applications/{application_id}/api-keys. @@ -176,6 +201,21 @@ type DashboardCrawlerError struct { Detail *string `json:"detail"` } +func (r *APIKeyResource) toAPIKey() APIKey { + return APIKey{ + UUID: r.ID, + ApplicationID: r.Attributes.ApplicationID, + Value: r.Attributes.Value, + Description: r.Attributes.Description, + ACL: r.Attributes.ACL, + Indexes: r.Attributes.Indexes, + Referers: r.Attributes.Referers, + MaxHitsPerQuery: r.Attributes.MaxHitsPerQuery, + MaxQueriesPerIPPerHour: r.Attributes.MaxQueriesPerIPPerHour, + QueryParameters: r.Attributes.QueryParameters, + } +} + // toApplication flattens a JSON:API resource into a simple Application. func (r *ApplicationResource) toApplication() Application { return Application{ diff --git a/pkg/auth/authenticate.go b/pkg/auth/authenticate.go index 3ca2f711..81a90117 100644 --- a/pkg/auth/authenticate.go +++ b/pkg/auth/authenticate.go @@ -21,7 +21,14 @@ func EnsureAuthenticated( } cs := io.ColorScheme() - fmt.Fprintf(io.Out, "%s %s\n", cs.WarningIcon(), err) + fmt.Fprintf(io.ErrOut, "%s %s\n", cs.WarningIcon(), err) + + if !io.CanPrompt() { + return "", fmt.Errorf( + "no usable session and authenticating requires a terminal: run %s", + cs.Bold("algolia auth login"), + ) + } // No flow tracker: this re-authentication belongs to the calling flow, // not to an `auth login` funnel. @@ -41,7 +48,14 @@ func ReauthenticateIfExpired( cs := io.ColorScheme() ClearToken() - fmt.Fprintf(io.Out, "%s Session expired.\n", cs.WarningIcon()) + fmt.Fprintf(io.ErrOut, "%s Session expired.\n", cs.WarningIcon()) + + if !io.CanPrompt() { + return "", fmt.Errorf( + "your session expired and authenticating requires a terminal: run %s", + cs.Bold("algolia auth login"), + ) + } // No flow tracker: this re-authentication belongs to the calling flow, // not to an `auth login` funnel. diff --git a/pkg/auth/authenticate_test.go b/pkg/auth/authenticate_test.go new file mode 100644 index 00000000..f04f7b3f --- /dev/null +++ b/pkg/auth/authenticate_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "testing" + + "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/iostreams" +) + +func TestEnsureAuthenticated_WithoutATerminalDoesNotStartTheOAuthFlow(t *testing.T) { + keyring.MockInit() + ClearToken() + + io, _, stdout, stderr := iostreams.Test() + require.False(t, io.CanPrompt()) + + _, err := EnsureAuthenticated(io, dashboard.NewClient("test")) + 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") +} + +func TestReauthenticateIfExpired_WithoutATerminalDoesNotStartTheOAuthFlow(t *testing.T) { + keyring.MockInit() + + io, _, stdout, stderr := iostreams.Test() + + _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), dashboard.ErrSessionExpired) + 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") +} + +func TestReauthenticateIfExpired_PassesThroughOtherErrors(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + + other := assert.AnError + _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), other) + assert.Same(t, other, err) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} diff --git a/pkg/cmd/apikeys/create/create.go b/pkg/cmd/apikeys/create/create.go index 0576b64e..15e5b035 100644 --- a/pkg/cmd/apikeys/create/create.go +++ b/pkg/cmd/apikeys/create/create.go @@ -1,7 +1,9 @@ package create import ( + "errors" "fmt" + "net/http" "time" "github.com/MakeNowJust/heredoc" @@ -9,18 +11,24 @@ import ( "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" "github.com/algolia/cli/pkg/validators" ) +const defaultDescription = "Created with the Algolia CLI" + // CreateOptions represents the options for the create command type CreateOptions struct { config config.IConfig IO *iostreams.IOStreams - SearchClient func() (*search.APIClient, error) + SearchClient func() (*search.APIClient, error) + NewDashboardClient func(clientID string) *dashboard.Client + LoadToken func() *auth.StoredToken ACL []string Description string @@ -37,23 +45,41 @@ func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co IO: f.IOStreams, config: f.Config, SearchClient: f.SearchClient, - PrintFlags: cmdutil.NewPrintFlags(), + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + LoadToken: auth.LoadToken, + PrintFlags: cmdutil.NewPrintFlags(), } cmd := &cobra.Command{ Use: "create", Aliases: []string{"new", "n", "c"}, Args: validators.NoArgs(), Annotations: map[string]string{ - "acls": "admin", + "skipAuthCheck": "true", }, Short: "Create a new API key", - Long: `Create a new API key with the provided parameters.`, + Long: heredoc.Doc(` + Create a new API key with the provided parameters. + + By default, the key is created for the current application through your + signed-in session, so no admin API key is needed. This path supports + --acl, --indices, --referers and --description. + + When the API key in use isn't the one the CLI provisioned for the current + application (--api-key, ALGOLIA_API_KEY, or a key stored by a config.toml + profile), the key is created with the Search API instead, which also + supports --validity. + `), Example: heredoc.Doc(` + # Create a search-only API key for the current application + $ algolia apikeys create --acl search + # Create a new API key targeting the index "MOVIES", with the "search" and "browse" ACL and a description $ algolia apikeys create --indices MOVIES --acl search,browse --description "Search & Browse API Key" # Create a new API key targeting the indices "MOVIES" and "SERIES", with the "https://example.com" referer, with a validity of 1 hour and a description - $ algolia apikeys create -i MOVIES,SERIES --acl search -r "https://example.com" --u 1h -d "Search-only API Key for MOVIES & SERIES" + $ algolia apikeys create -i MOVIES,SERIES --acl search -r "https://example.com" --u 1h -d "Search-only API Key for MOVIES & SERIES" --api-key `), RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { @@ -90,7 +116,8 @@ func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co `, "`")) cmd.Flags().DurationVarP(&opts.Validity, "validity", "u", 0, heredoc.Doc(` - Duration (in seconds) after which the API key expires. By default (a value of 0), API keys don't expire.`, + Duration (in seconds) after which the API key expires. By default (a value of 0), API keys don't expire. + Requires an admin API key (--api-key), as expiring keys aren't supported for the signed-in session.`, )) cmd.Flags().StringSliceVarP(&opts.Referers, "referers", "r", nil, heredoc.Docf(` @@ -148,6 +175,120 @@ func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co // runCreateCmd executes the create command func runCreateCmd(opts *CreateOptions) error { + if !config.ShouldUseSessionAPIKey(opts.config) { + return runCreateWithSearchAPI(opts) + } + + if opts.LoadToken() == nil { + return runCreateWithSearchAPI(opts) + } + + return runCreateWithDashboardAPI(opts) +} + +func runCreateWithDashboardAPI(opts *CreateOptions) error { + cs := opts.IO.ColorScheme() + + if len(opts.ACL) == 0 { + return fmt.Errorf( + "--acl is required, for example %s", + cs.Bold("algolia apikeys create --acl search"), + ) + } + if opts.Validity != 0 { + return fmt.Errorf( + "--validity requires an admin API key: pass %s, or drop --validity", + cs.Bold("--api-key"), + ) + } + + 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"), + ) + } + + description := opts.Description + if description == "" { + description = defaultDescription + } + + params := dashboard.CreateAPIKeyRequest{ + ACL: opts.ACL, + Description: description, + Indexes: opts.Indices, + Referers: opts.Referers, + } + + client := opts.NewDashboardClient(auth.OAuthClientID()) + + key, err := createKeyWithSession(opts, client, appID, params) + if err != nil { + if errors.Is(err, dashboard.ErrApplicationNotFound) { + return fmt.Errorf( + "application %s doesn't exist, or your account doesn't have access to it: run %s to pick one of your applications", + cs.Bold(appID), + cs.Bold("algolia application select"), + ) + } + return err + } + + if opts.PrintFlags.HasStructuredOutput() { + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + return p.Print(opts.IO, key) + } + + printCreatedKey(opts.IO, key.Value) + + return nil +} + +func printCreatedKey(io *iostreams.IOStreams, value string) { + if !io.IsStdoutTTY() { + fmt.Fprintln(io.Out, value) + return + } + + fmt.Fprintf(io.Out, "%s API key created: %s\n", io.ColorScheme().SuccessIcon(), value) +} + +func createKeyWithSession( + opts *CreateOptions, + client *dashboard.Client, + appID string, + params dashboard.CreateAPIKeyRequest, +) (dashboard.APIKey, error) { + accessToken, err := auth.EnsureAuthenticated(opts.IO, client) + if err != nil { + return dashboard.APIKey{}, err + } + + opts.IO.StartProgressIndicatorWithLabel("Creating API key") + key, err := client.CreateAPIKeyWithParams(accessToken, appID, params) + opts.IO.StopProgressIndicator() + if err == nil { + return key, nil + } + + accessToken, err = auth.ReauthenticateIfExpired(opts.IO, client, err) + if err != nil { + return dashboard.APIKey{}, err + } + + opts.IO.StartProgressIndicatorWithLabel("Creating API key") + key, err = client.CreateAPIKeyWithParams(accessToken, appID, params) + opts.IO.StopProgressIndicator() + + return key, err +} + +func runCreateWithSearchAPI(opts *CreateOptions) error { var acls []search.Acl for _, a := range opts.ACL { acls = append(acls, search.Acl(a)) @@ -167,10 +308,10 @@ func runCreateCmd(opts *CreateOptions) error { } res, err := client.AddApiKey(client.NewApiAddApiKeyRequest(&key)) if err != nil { - return err + return searchAPICreateError(opts, err) } - if opts.PrintFlags.OutputFlagSpecified() && opts.PrintFlags.OutputFormat != nil { + if opts.PrintFlags.HasStructuredOutput() { p, err := opts.PrintFlags.ToPrinter() if err != nil { return err @@ -178,9 +319,30 @@ func runCreateCmd(opts *CreateOptions) error { return p.Print(opts.IO, res) } + printCreatedKey(opts.IO, res.Key) + + return nil +} + +func searchAPICreateError(opts *CreateOptions, err error) error { + var apiErr *search.APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusForbidden { + return err + } + cs := opts.IO.ColorScheme() - if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "%s API key created: %s\n", cs.SuccessIcon(), res.Key) + if config.ShouldUseSessionAPIKey(opts.config) { + return fmt.Errorf( + "%w\nRun %s to create API keys without an admin key", + err, + cs.Bold("algolia auth login"), + ) } - return nil + + 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 create the key with your signed-in session", + err, + cs.Bold("--api-key"), + cs.Bold("ALGOLIA_API_KEY"), + ) } diff --git a/pkg/cmd/apikeys/create/create_test.go b/pkg/cmd/apikeys/create/create_test.go index b5d00b21..3a1bfbaf 100644 --- a/pkg/cmd/apikeys/create/create_test.go +++ b/pkg/cmd/apikeys/create/create_test.go @@ -1,6 +1,11 @@ package create import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" "testing" "time" @@ -8,13 +13,132 @@ import ( "github.com/google/shlex" "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 ( + createdValue = "new-value" + createdUUID = "uuid-123" +) + +func withoutSession(t *testing.T) { + t.Helper() + t.Setenv("ALGOLIA_API_KEY", "") + t.Setenv("ALGOLIA_ADMIN_API_KEY", "") + t.Setenv("ALGOLIA_APPLICATION_ID", "") + keyring.MockInit() +} + +func withSession(t *testing.T) { + t.Helper() + withoutSession(t) + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "tok-1", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) +} + +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 createdKeyResponse() dashboard.CreateAPIKeyResponse { + maxHits := 42 + maxQueries := 1000 + queryParameters := "filters=visible:true" + + return dashboard.CreateAPIKeyResponse{ + Data: dashboard.APIKeyResource{ + ID: createdUUID, + Type: "api_key", + Attributes: dashboard.APIKeyAttributes{ + Value: createdValue, + ApplicationID: "APP1", + ACL: []string{"browse"}, + Description: "stored description", + Indexes: []string{"SERIES"}, + Referers: []string{"https://stored.example.com"}, + MaxHitsPerQuery: &maxHits, + MaxQueriesPerIPPerHour: &maxQueries, + QueryParameters: &queryParameters, + }, + }, + } +} + +// createKeyServer stubs the dashboard create endpoint at wantPath, recording the +// received payload. +func createKeyServer( + t *testing.T, + wantPath string, + got *dashboard.CreateAPIKeyRequest, +) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc(wantPath, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + require.NoError(t, json.NewDecoder(r.Body).Decode(got)) + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(createdKeyResponse())) + }) + return httptest.NewServer(mux) +} + +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 newDashboardOpts( + t *testing.T, + srv *httptest.Server, + isTTY bool, +) (*CreateOptions, *bytes.Buffer) { + t.Helper() + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(isTTY) + + opts := &CreateOptions{ + IO: io, + config: managedKeyConfig(), + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + }, + LoadToken: auth.LoadToken, + PrintFlags: cmdutil.NewPrintFlags(), + } + return opts, stdout +} + func TestNewCreateCmd(t *testing.T) { oneHour, _ := time.ParseDuration("1h") @@ -81,6 +205,15 @@ func TestNewCreateCmd(t *testing.T) { } } +func TestNewCreateCmd_SkipsTheAdminACLCheck(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: io} + cmd := NewCreateCmd(f, nil) + + assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) + assert.Empty(t, cmd.Annotations["acls"]) +} + func Test_runCreateCmd(t *testing.T) { tests := []struct { name string @@ -89,10 +222,10 @@ func Test_runCreateCmd(t *testing.T) { wantOut string }{ { - name: "no TTY", + name: "no TTY prints the bare key", cli: "", isTTY: false, - wantOut: "", + wantOut: "foo\n", }, { name: "TTY", @@ -104,6 +237,8 @@ func Test_runCreateCmd(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + withoutSession(t) + r := httpmock.Registry{} r.Register( httpmock.REST("POST", "1/keys"), @@ -121,3 +256,271 @@ func Test_runCreateCmd(t *testing.T) { }) } } + +func Test_runCreateCmd_ExplicitAPIKeyUsesTheSearchAPI(t *testing.T) { + withSession(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/keys"), + httpmock.JSONResponse(search.AddApiKeyResponse{Key: "from-sapi"}), + ) + + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "admin-key" + + f, out := test.NewFactory(true, &r, cfg, "") + cmd := NewCreateCmd(f, func(o *CreateOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runCreateCmd(o) + }) + out, err := test.Execute(cmd, "--acl search", out) + require.NoError(t, err) + + assert.Contains(t, out.String(), "from-sapi") +} + +func Test_runCreateCmd_EnvAPIKeyUsesTheSearchAPI(t *testing.T) { + withSession(t) + t.Setenv("ALGOLIA_API_KEY", "env-admin-key") + + r := httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/keys"), + httpmock.JSONResponse(search.AddApiKeyResponse{Key: "from-sapi"}), + ) + + f, out := test.NewFactory(true, &r, managedKeyConfig(), "") + cmd := NewCreateCmd(f, func(o *CreateOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runCreateCmd(o) + }) + out, err := test.Execute(cmd, "--acl search", out) + require.NoError(t, err) + + assert.Contains(t, out.String(), "from-sapi") +} + +func Test_runCreateCmd_WithSession(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout := newDashboardOpts(t, srv, true) + opts.ACL = []string{"search", "browse"} + opts.Indices = []string{"MOVIES"} + opts.Referers = []string{"https://example.com"} + + require.NoError(t, runCreateCmd(opts)) + + assert.Equal(t, []string{"search", "browse"}, got.ACL) + assert.Equal(t, []string{"MOVIES"}, got.Indexes) + assert.Equal(t, []string{"https://example.com"}, got.Referers) + assert.Equal(t, defaultDescription, got.Description) + assert.Equal(t, "✓ API key created: "+createdValue+"\n", stdout.String()) +} + +func Test_runCreateCmd_WithSessionNoTTYPrintsTheBareKey(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout := newDashboardOpts(t, srv, false) + opts.ACL = []string{"search"} + + require.NoError(t, runCreateCmd(opts)) + + assert.Equal(t, createdValue+"\n", stdout.String()) +} + +func Test_runCreateCmd_WithSessionKeepsTheGivenDescription(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, _ := newDashboardOpts(t, srv, true) + opts.ACL = []string{"search"} + opts.Description = "frontend search key" + + require.NoError(t, runCreateCmd(opts)) + + assert.Equal(t, "frontend search key", got.Description) +} + +func Test_runCreateCmd_WithSessionStructuredOutput(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout := newDashboardOpts(t, srv, false) + opts.ACL = []string{"search"} + format := "json" + opts.PrintFlags.OutputFormat = &format + + require.NoError(t, runCreateCmd(opts)) + + var key dashboard.APIKey + require.NoError(t, json.Unmarshal(stdout.Bytes(), &key)) + assert.Equal(t, createdValue, key.Value) + assert.Equal(t, createdUUID, key.UUID) + assert.Equal(t, "APP1", key.ApplicationID) + assert.Equal(t, []string{"browse"}, key.ACL) + assert.Equal(t, "stored description", key.Description) + assert.Equal(t, []string{"SERIES"}, key.Indexes) + assert.Equal(t, []string{"https://stored.example.com"}, key.Referers) + require.NotNil(t, key.MaxHitsPerQuery) + assert.Equal(t, 42, *key.MaxHitsPerQuery) + require.NotNil(t, key.MaxQueriesPerIPPerHour) + assert.Equal(t, 1000, *key.MaxQueriesPerIPPerHour) + require.NotNil(t, key.QueryParameters) + assert.Equal(t, "filters=visible:true", *key.QueryParameters) +} + +func Test_runCreateCmd_WithSessionUnknownApplication(t *testing.T) { + withSession(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, _ := newDashboardOpts(t, srv, true) + opts.ACL = []string{"search"} + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "APP1") + assert.Contains(t, err.Error(), "doesn't have access to it") + assert.Contains(t, err.Error(), "algolia application select") +} + +func Test_runCreateCmd_WithSessionValidationErrors(t *testing.T) { + tests := []struct { + name string + opts func(*CreateOptions) + wantErr string + }{ + { + name: "no ACL", + opts: func(o *CreateOptions) {}, + wantErr: "--acl is required", + }, + { + name: "validity", + opts: func(o *CreateOptions) { + o.ACL = []string{"search"} + o.Validity = time.Hour + }, + wantErr: "--validity requires an admin API key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, _ := newDashboardOpts(t, srv, true) + tt.opts(opts) + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func Test_runCreateCmd_SignedInWithoutAnApplication(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, _ := newDashboardOpts(t, srv, true) + opts.ACL = []string{"search"} + opts.config = &test.ConfigStub{} + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no application selected") + assert.Contains(t, err.Error(), "algolia application select") +} + +func Test_runCreateCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout := newDashboardOpts(t, srv, false) + opts.ACL = []string{"search"} + opts.config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} + + require.NoError(t, runCreateCmd(opts)) + + assert.Equal(t, []string{"search"}, got.ACL) + assert.Equal(t, createdValue+"\n", stdout.String()) +} + +func Test_searchAPICreateError(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 := &CreateOptions{IO: io, config: cfg} + + err := searchAPICreateError(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 := &CreateOptions{IO: io, config: managedKeyConfig()} + + err := searchAPICreateError(opts, forbidden) + require.Error(t, err) + assert.Contains(t, err.Error(), "algolia auth login") + assert.NotContains(t, err.Error(), "--api-key") + }) + + t.Run("non-403 errors pass through", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + opts := &CreateOptions{IO: io, config: managedKeyConfig()} + + other := &search.APIError{Status: http.StatusBadRequest, Message: "nope"} + assert.Same(t, other, searchAPICreateError(opts, other)) + + plain := errors.New("boom") + assert.Same(t, plain, searchAPICreateError(opts, plain)) + }) +} diff --git a/pkg/config/profile.go b/pkg/config/profile.go index d92b2a17..889624b1 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -120,6 +120,25 @@ func (p *Profile) GetAPIKey() (string, error) { return p.GetAdminAPIKey() } +func ShouldUseSessionAPIKey(cfg IConfig) bool { + if os.Getenv("ALGOLIA_API_KEY") != "" || cfg.Profile().APIKey != "" { + return false + } + + appID, err := cfg.Profile().GetApplicationID() + if err != nil || appID == "" { + return true + } + + if _, hasUUID := cfg.APIKeyUUID(appID); hasUUID { + return true + } + + _, err = cfg.Profile().GetAPIKey() + + return err != nil +} + func (p *Profile) GetAdminAPIKey() (string, error) { if os.Getenv("ALGOLIA_ADMIN_API_KEY") != "" { return os.Getenv("ALGOLIA_ADMIN_API_KEY"), nil diff --git a/pkg/config/resolution_test.go b/pkg/config/resolution_test.go index 79f9a95a..1f9dd206 100644 --- a/pkg/config/resolution_test.go +++ b/pkg/config/resolution_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "github.com/spf13/viper" @@ -13,6 +14,16 @@ import ( "github.com/algolia/cli/pkg/keychain" ) +func TestMain(m *testing.M) { + for _, entry := range os.Environ() { + if name, _, ok := strings.Cut(entry, "="); ok && strings.HasPrefix(name, "ALGOLIA_") { + _ = os.Unsetenv(name) + } + } + + os.Exit(m.Run()) +} + func TestConfig_LoadStateCachesAndToleratesMissing(t *testing.T) { cfg := &Config{StateFile: filepath.Join(t.TempDir(), "state.toml")} @@ -305,6 +316,115 @@ func TestProfile_GetCrawlerUserID_FromState(t *testing.T) { assert.Equal(t, "crawler-user", userID) } +func TestShouldUseSessionAPIKey(t *testing.T) { + newConfig := func(t *testing.T, state string) *Config { + t.Helper() + t.Setenv("ALGOLIA_API_KEY", "") + t.Setenv("ALGOLIA_ADMIN_API_KEY", "") + t.Setenv("ALGOLIA_APPLICATION_ID", "") + keyring.MockInit() + viper.Reset() + t.Cleanup(viper.Reset) + + cfg := &Config{StateFile: state} + cfg.CurrentProfile.config = cfg + + return cfg + } + + writeState := func(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "state.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + return path + } + + loadConfigToml := func(t *testing.T, content string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + viper.SetConfigType("toml") + viper.SetConfigFile(path) + require.NoError(t, viper.ReadInConfig()) + } + + managedState := "current_application_id = \"MANAGED\"\n\n[applications.MANAGED]\nalias = \"prod\"\napi_key_uuid = \"uuid-1\"\n" + reusedState := "current_application_id = \"REUSED\"\n\n[applications.REUSED]\nalias = \"legacy\"\n" + + t.Run("active application with a stored UUID", func(t *testing.T) { + cfg := newConfig(t, writeState(t, managedState)) + require.NoError( + t, + keychain.SaveAppSecrets("MANAGED", keychain.AppSecrets{APIKey: "cli-key"}), + ) + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("application id flag with an empty state file", func(t *testing.T) { + cfg := newConfig(t, writeState(t, "")) + cfg.CurrentProfile.ApplicationID = "FLAG_APP" + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("nothing configured", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("legacy config.toml profile with an api key", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + loadConfigToml( + t, + "[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy-key\"\ndefault = true\n", + ) + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("ALGOLIA_API_KEY set", func(t *testing.T) { + cfg := newConfig(t, writeState(t, managedState)) + t.Setenv("ALGOLIA_API_KEY", "env-key") + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("api key flag set", func(t *testing.T) { + cfg := newConfig(t, writeState(t, managedState)) + cfg.CurrentProfile.APIKey = "flag-key" + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("reused keychain key without a UUID", func(t *testing.T) { + cfg := newConfig(t, writeState(t, reusedState)) + require.NoError( + t, + keychain.SaveAppSecrets("REUSED", keychain.AppSecrets{APIKey: "old-key"}), + ) + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("stored UUID but no key in the keychain", func(t *testing.T) { + cfg := newConfig(t, writeState(t, managedState)) + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("uninitialized config", func(t *testing.T) { + t.Setenv("ALGOLIA_API_KEY", "") + t.Setenv("ALGOLIA_APPLICATION_ID", "") + viper.Reset() + t.Cleanup(viper.Reset) + + assert.True(t, ShouldUseSessionAPIKey(&Config{})) + }) +} + func TestConfig_ApplicationInState(t *testing.T) { path := filepath.Join(t.TempDir(), "state.toml") require.NoError(t, os.WriteFile(path, []byte( From 2aee23fe41f6e8b9c24bffacc6b183ce399b9a02 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 13:35:29 -0700 Subject: [PATCH 2/7] fix(apikeys): harden create output, auth and help accuracy --- pkg/auth/auth_check.go | 26 +++ pkg/auth/auth_check_test.go | 49 +++++ pkg/auth/authenticate.go | 3 +- pkg/auth/authenticate_test.go | 20 ++ pkg/cmd/apikeys/create/create.go | 75 ++++--- pkg/cmd/apikeys/create/create_test.go | 287 ++++++++++++++++++++++++-- pkg/cmd/root/root.go | 13 +- pkg/config/resolution_test.go | 32 +++ 8 files changed, 441 insertions(+), 64 deletions(-) diff --git a/pkg/auth/auth_check.go b/pkg/auth/auth_check.go index dfe2ae4f..f7857c67 100644 --- a/pkg/auth/auth_check.go +++ b/pkg/auth/auth_check.go @@ -66,6 +66,32 @@ func CheckAuth(cfg *config.Config) error { return nil } +func Remediation(err error) string { + if errors.Is(err, config.ErrAPIKeyMissingFromKeychain) { + return "" + } + if (errors.Is(err, config.ErrApplicationIDNotConfigured) || errors.Is(err, config.ErrAPIKeyNotConfigured)) && + LoadToken() != nil { + return "You're signed in, but no application is selected.\nRun `algolia application select`, or pass --application-id." + } + + return "Please run `algolia auth login` to get started." +} + +func WithRemediation(err error) error { + if !errors.Is(err, config.ErrApplicationIDNotConfigured) && + !errors.Is(err, config.ErrAPIKeyNotConfigured) { + return err + } + + hint := Remediation(err) + if hint == "" { + return err + } + + return fmt.Errorf("%w\n%s", err, hint) +} + // CheckACLs check if the current profile has the right ACLs to execute the command func CheckACLs(cmd *cobra.Command, f *cmdutil.Factory) error { if cmd.Annotations == nil { diff --git a/pkg/auth/auth_check_test.go b/pkg/auth/auth_check_test.go index 90b584f3..fac8075a 100644 --- a/pkg/auth/auth_check_test.go +++ b/pkg/auth/auth_check_test.go @@ -3,15 +3,64 @@ package auth import ( "os" "testing" + "time" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/httpmock" "github.com/algolia/cli/test" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" ) +func TestRemediation(t *testing.T) { + t.Run("signed out", func(t *testing.T) { + keyring.MockInit() + + assert.Contains( + t, + Remediation(config.ErrApplicationIDNotConfigured), + "algolia auth login", + ) + }) + + t.Run("signed in", func(t *testing.T) { + keyring.MockInit() + require.NoError(t, SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "tok-1", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) + + assert.Contains( + t, + Remediation(config.ErrAPIKeyNotConfigured), + "algolia application select", + ) + }) + + t.Run("api key missing from the keychain", func(t *testing.T) { + keyring.MockInit() + + assert.Empty(t, Remediation(config.ErrAPIKeyMissingFromKeychain)) + }) +} + +func TestWithRemediation(t *testing.T) { + keyring.MockInit() + + wrapped := WithRemediation(config.ErrAPIKeyNotConfigured) + require.ErrorIs(t, wrapped, config.ErrAPIKeyNotConfigured) + assert.Contains(t, wrapped.Error(), "algolia auth login") + + other := assert.AnError + assert.Same(t, other, WithRemediation(other)) +} + func Test_CheckACLs(t *testing.T) { // Remove these environment variables before the tests os.Unsetenv("ALGOLIA_APPLICATION_ID") diff --git a/pkg/auth/authenticate.go b/pkg/auth/authenticate.go index 81a90117..a44108dc 100644 --- a/pkg/auth/authenticate.go +++ b/pkg/auth/authenticate.go @@ -47,7 +47,6 @@ func ReauthenticateIfExpired( } cs := io.ColorScheme() - ClearToken() fmt.Fprintf(io.ErrOut, "%s Session expired.\n", cs.WarningIcon()) if !io.CanPrompt() { @@ -57,6 +56,8 @@ func ReauthenticateIfExpired( ) } + ClearToken() + // No flow tracker: this re-authentication belongs to the calling flow, // not to an `auth login` funnel. return RunOAuth(io, client, false, true, nil) diff --git a/pkg/auth/authenticate_test.go b/pkg/auth/authenticate_test.go index f04f7b3f..bc5c5e93 100644 --- a/pkg/auth/authenticate_test.go +++ b/pkg/auth/authenticate_test.go @@ -2,6 +2,7 @@ package auth import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -41,6 +42,25 @@ func TestReauthenticateIfExpired_WithoutATerminalDoesNotStartTheOAuthFlow(t *tes assert.Contains(t, stderr.String(), "Session expired") } +func TestReauthenticateIfExpired_WithoutATerminalKeepsTheStoredToken(t *testing.T) { + keyring.MockInit() + require.NoError(t, SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "tok-1", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) + + io, _, _, _ := iostreams.Test() + require.False(t, io.CanPrompt()) + + _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), dashboard.ErrSessionExpired) + require.Error(t, err) + + stored := LoadToken() + require.NotNil(t, stored) + assert.Equal(t, "tok-1", stored.AccessToken) +} + func TestReauthenticateIfExpired_PassesThroughOtherErrors(t *testing.T) { io, _, stdout, stderr := iostreams.Test() diff --git a/pkg/cmd/apikeys/create/create.go b/pkg/cmd/apikeys/create/create.go index 15e5b035..1872ec73 100644 --- a/pkg/cmd/apikeys/create/create.go +++ b/pkg/cmd/apikeys/create/create.go @@ -16,6 +16,7 @@ import ( "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/printers" "github.com/algolia/cli/pkg/validators" ) @@ -28,7 +29,6 @@ type CreateOptions struct { SearchClient func() (*search.APIClient, error) NewDashboardClient func(clientID string) *dashboard.Client - LoadToken func() *auth.StoredToken ACL []string Description string @@ -48,7 +48,6 @@ func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID) }, - LoadToken: auth.LoadToken, PrintFlags: cmdutil.NewPrintFlags(), } cmd := &cobra.Command{ @@ -66,10 +65,14 @@ func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co signed-in session, so no admin API key is needed. This path supports --acl, --indices, --referers and --description. - When the API key in use isn't the one the CLI provisioned for the current - application (--api-key, ALGOLIA_API_KEY, or a key stored by a config.toml - profile), the key is created with the Search API instead, which also - supports --validity. + The key is created with the Search API instead, which also supports + --validity, 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(` # Create a search-only API key for the current application @@ -117,7 +120,7 @@ func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().DurationVarP(&opts.Validity, "validity", "u", 0, heredoc.Doc(` Duration (in seconds) after which the API key expires. By default (a value of 0), API keys don't expire. - Requires an admin API key (--api-key), as expiring keys aren't supported for the signed-in session.`, + Requires an API key allowed to create keys (--api-key or ALGOLIA_API_KEY): the signed-in session can't create expiring keys.`, )) cmd.Flags().StringSliceVarP(&opts.Referers, "referers", "r", nil, heredoc.Docf(` @@ -175,33 +178,43 @@ func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co // runCreateCmd executes the create command func runCreateCmd(opts *CreateOptions) error { - if !config.ShouldUseSessionAPIKey(opts.config) { - return runCreateWithSearchAPI(opts) + if len(opts.ACL) == 0 { + return fmt.Errorf( + "--acl is required, for example %s", + opts.IO.ColorScheme().Bold("algolia apikeys create --acl search"), + ) } - if opts.LoadToken() == nil { + if !config.ShouldUseSessionAPIKey(opts.config) { return runCreateWithSearchAPI(opts) } return runCreateWithDashboardAPI(opts) } +func structuredPrinter(opts *CreateOptions) (printers.Printer, error) { + if !opts.PrintFlags.HasStructuredOutput() { + return nil, nil + } + + return opts.PrintFlags.ToPrinter() +} + func runCreateWithDashboardAPI(opts *CreateOptions) error { cs := opts.IO.ColorScheme() - if len(opts.ACL) == 0 { - return fmt.Errorf( - "--acl is required, for example %s", - cs.Bold("algolia apikeys create --acl search"), - ) - } if opts.Validity != 0 { return fmt.Errorf( - "--validity requires an admin API key: pass %s, or drop --validity", + "--validity isn't supported with your signed-in session: pass %s, or drop --validity", cs.Bold("--api-key"), ) } + printer, err := structuredPrinter(opts) + if err != nil { + return err + } + appID, err := opts.config.Profile().GetApplicationID() if err != nil { return fmt.Errorf( @@ -236,12 +249,8 @@ func runCreateWithDashboardAPI(opts *CreateOptions) error { return err } - if opts.PrintFlags.HasStructuredOutput() { - p, err := opts.PrintFlags.ToPrinter() - if err != nil { - return err - } - return p.Print(opts.IO, key) + if printer != nil { + return printer.Print(opts.IO, createdKeyOutput{APIKey: key, Key: key.Value}) } printCreatedKey(opts.IO, key.Value) @@ -249,6 +258,11 @@ func runCreateWithDashboardAPI(opts *CreateOptions) error { return nil } +type createdKeyOutput struct { + dashboard.APIKey + Key string `json:"key"` +} + func printCreatedKey(io *iostreams.IOStreams, value string) { if !io.IsStdoutTTY() { fmt.Fprintln(io.Out, value) @@ -289,6 +303,11 @@ func createKeyWithSession( } func runCreateWithSearchAPI(opts *CreateOptions) error { + printer, err := structuredPrinter(opts) + if err != nil { + return err + } + var acls []search.Acl for _, a := range opts.ACL { acls = append(acls, search.Acl(a)) @@ -304,19 +323,15 @@ func runCreateWithSearchAPI(opts *CreateOptions) error { client, err := opts.SearchClient() if err != nil { - return err + return auth.WithRemediation(err) } res, err := client.AddApiKey(client.NewApiAddApiKeyRequest(&key)) if err != nil { return searchAPICreateError(opts, err) } - if opts.PrintFlags.HasStructuredOutput() { - p, err := opts.PrintFlags.ToPrinter() - if err != nil { - return err - } - return p.Print(opts.IO, res) + if printer != nil { + return printer.Print(opts.IO, res) } printCreatedKey(opts.IO, res.Key) diff --git a/pkg/cmd/apikeys/create/create_test.go b/pkg/cmd/apikeys/create/create_test.go index 3a1bfbaf..0f1faeb0 100644 --- a/pkg/cmd/apikeys/create/create_test.go +++ b/pkg/cmd/apikeys/create/create_test.go @@ -25,15 +25,23 @@ import ( ) const ( - createdValue = "new-value" - createdUUID = "uuid-123" + createdValue = "new-value" + createdUUID = "uuid-123" + unroutableAPIURL = "http://127.0.0.1:1" ) +type ttys struct { + stdin bool + stdout bool + stderr bool +} + 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() } @@ -57,6 +65,13 @@ func managedKeyConfig() *test.ConfigStub { } } +func explicitKeyConfig() *test.ConfigStub { + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "adm" + + return cfg +} + func createdKeyResponse() dashboard.CreateAPIKeyResponse { maxHits := 42 maxQueries := 1000 @@ -118,12 +133,14 @@ func unusedDashboardClient(t *testing.T) func(string) *dashboard.Client { func newDashboardOpts( t *testing.T, srv *httptest.Server, - isTTY bool, -) (*CreateOptions, *bytes.Buffer) { + tty ttys, +) (*CreateOptions, *bytes.Buffer, *bytes.Buffer) { t.Helper() - io, _, stdout, _ := iostreams.Test() - io.SetStdoutTTY(isTTY) + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(tty.stdin) + io.SetStdoutTTY(tty.stdout) + io.SetStderrTTY(tty.stderr) opts := &CreateOptions{ IO: io, @@ -133,10 +150,9 @@ func newDashboardOpts( c.APIURL = srv.URL return c }, - LoadToken: auth.LoadToken, PrintFlags: cmdutil.NewPrintFlags(), } - return opts, stdout + return opts, stdout, stderr } func TestNewCreateCmd(t *testing.T) { @@ -223,13 +239,13 @@ func Test_runCreateCmd(t *testing.T) { }{ { name: "no TTY prints the bare key", - cli: "", + cli: "--acl search", isTTY: false, wantOut: "foo\n", }, { name: "TTY", - cli: "", + cli: "--acl search", isTTY: true, wantOut: "✓ API key created: foo\n", }, @@ -245,8 +261,11 @@ func Test_runCreateCmd(t *testing.T) { httpmock.JSONResponse(search.AddApiKeyResponse{Key: "foo"}), ) - f, out := test.NewFactory(tt.isTTY, &r, nil, "") - cmd := NewCreateCmd(f, nil) + f, out := test.NewFactory(tt.isTTY, &r, explicitKeyConfig(), "") + cmd := NewCreateCmd(f, func(o *CreateOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runCreateCmd(o) + }) out, err := test.Execute(cmd, tt.cli, out) if err != nil { t.Fatal(err) @@ -308,7 +327,7 @@ func Test_runCreateCmd_WithSession(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, stdout := newDashboardOpts(t, srv, true) + opts, stdout, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) opts.ACL = []string{"search", "browse"} opts.Indices = []string{"MOVIES"} opts.Referers = []string{"https://example.com"} @@ -329,7 +348,7 @@ func Test_runCreateCmd_WithSessionNoTTYPrintsTheBareKey(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, stdout := newDashboardOpts(t, srv, false) + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) opts.ACL = []string{"search"} require.NoError(t, runCreateCmd(opts)) @@ -344,7 +363,7 @@ func Test_runCreateCmd_WithSessionKeepsTheGivenDescription(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, _ := newDashboardOpts(t, srv, true) + opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) opts.ACL = []string{"search"} opts.Description = "frontend search key" @@ -360,7 +379,7 @@ func Test_runCreateCmd_WithSessionStructuredOutput(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, stdout := newDashboardOpts(t, srv, false) + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) opts.ACL = []string{"search"} format := "json" opts.PrintFlags.OutputFormat = &format @@ -398,7 +417,7 @@ func Test_runCreateCmd_WithSessionUnknownApplication(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - opts, _ := newDashboardOpts(t, srv, true) + opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) opts.ACL = []string{"search"} err := runCreateCmd(opts) @@ -425,7 +444,7 @@ func Test_runCreateCmd_WithSessionValidationErrors(t *testing.T) { o.ACL = []string{"search"} o.Validity = time.Hour }, - wantErr: "--validity requires an admin API key", + wantErr: "--validity isn't supported with your signed-in session", }, } @@ -437,7 +456,7 @@ func Test_runCreateCmd_WithSessionValidationErrors(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, _ := newDashboardOpts(t, srv, true) + opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) tt.opts(opts) err := runCreateCmd(opts) @@ -454,7 +473,7 @@ func Test_runCreateCmd_SignedInWithoutAnApplication(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, _ := newDashboardOpts(t, srv, true) + opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) opts.ACL = []string{"search"} opts.config = &test.ConfigStub{} @@ -471,7 +490,7 @@ func Test_runCreateCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testi srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, stdout := newDashboardOpts(t, srv, false) + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) opts.ACL = []string{"search"} opts.config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} @@ -481,6 +500,232 @@ func Test_runCreateCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testi assert.Equal(t, createdValue+"\n", stdout.String()) } +func Test_runCreateCmd_ACLIsRequiredOnTheSearchAPIPath(t *testing.T) { + withoutSession(t) + + r := httpmock.Registry{} + r.Register(httpmock.REST("POST", "1/keys"), func(*http.Request) (*http.Response, error) { + t.Error("no key must be created without --acl") + return nil, errors.New("unexpected request") + }) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewCreateCmd(f, nil) + _, err := test.Execute(cmd, "", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--acl is required") +} + +func Test_runCreateCmd_SessionStructuredOutputExposesTheKey(t *testing.T) { + tests := []struct { + name string + format string + template string + want string + }{ + { + name: "json", + format: "json", + want: `"key":"` + createdValue + `"`, + }, + { + name: "jsonpath", + format: "jsonpath", + template: "{.key}", + want: createdValue + "\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + format := tt.format + opts.PrintFlags.OutputFormat = &format + if tt.template != "" { + template := tt.template + opts.PrintFlags.JSONPathPrintFlags.TemplateArgument = &template + } + + require.NoError(t, runCreateCmd(opts)) + assert.Contains(t, stdout.String(), tt.want) + }) + } +} + +func Test_runCreateCmd_SearchAPIStructuredOutputExposesTheKey(t *testing.T) { + tests := []struct { + name string + cli string + want string + }{ + { + name: "json", + cli: "--acl search -o json", + want: `"key":"foo"`, + }, + { + name: "jsonpath", + cli: "--acl search -o jsonpath --template {.key}", + want: "foo\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withoutSession(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/keys"), + httpmock.JSONResponse(search.AddApiKeyResponse{Key: "foo"}), + ) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewCreateCmd(f, nil) + out, err := test.Execute(cmd, tt.cli, out) + require.NoError(t, err) + + assert.Contains(t, out.String(), tt.want) + }) + } +} + +func Test_runCreateCmd_UnsupportedOutputFormatFailsBeforeCreating(t *testing.T) { + t.Run("session path", func(t *testing.T) { + withSession(t) + + srv := httptest.NewServer( + http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("no key must be created: %s %s", r.Method, r.URL.Path) + }), + ) + defer srv.Close() + + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + format := "xml" + opts.PrintFlags.OutputFormat = &format + + err := runCreateCmd(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) + + r := httpmock.Registry{} + r.Register(httpmock.REST("POST", "1/keys"), func(*http.Request) (*http.Response, error) { + t.Error("no key must be created") + return nil, errors.New("unexpected request") + }) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewCreateCmd(f, nil) + _, err := test.Execute(cmd, "--acl search -o xml", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to match a printer") + assert.Empty(t, out.String()) + }) +} + +func Test_runCreateCmd_WithoutASessionOrAnApplication(t *testing.T) { + withoutSession(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, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + opts.config = &test.ConfigStub{} + + err := runCreateCmd(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_runCreateCmd_WithoutASessionStdoutPipedStderrTTY(t *testing.T) { + withoutSession(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 := newDashboardOpts(t, srv, ttys{stdin: true, stderr: true}) + opts.ACL = []string{"search"} + require.False(t, opts.IO.CanPrompt()) + + err := runCreateCmd(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, _, _ := newDashboardOpts(t, srv, ttys{stdin: true, stdout: true, stderr: true}) + assert.True(t, prompting.IO.CanPrompt()) +} + +func Test_runCreateCmd_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") + + io, _, stdout, _ := iostreams.Test() + opts := &CreateOptions{ + IO: io, + config: &test.ConfigStub{}, + ACL: []string{"search"}, + SearchClient: func() (*search.APIClient, error) { + return nil, config.ErrApplicationIDNotConfigured + }, + NewDashboardClient: unusedDashboardClient(t), + PrintFlags: cmdutil.NewPrintFlags(), + } + + err := runCreateCmd(opts) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrApplicationIDNotConfigured) + assert.Contains(t, err.Error(), tt.want) + assert.Empty(t, stdout.String()) + }) + } +} + func Test_searchAPICreateError(t *testing.T) { forbidden := &search.APIError{Status: http.StatusForbidden, Message: "Not enough rights"} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 859382b5..f8042333 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -166,7 +166,7 @@ func Execute() (code exitCode) { if auth.IsAuthCheckEnabled(cmd) { if err := auth.CheckAuth(&cfg); err != nil { fmt.Fprintf(stderr, "Authentication error: %s\n", err) - if hint := authRemediation(err); hint != "" { + if hint := auth.Remediation(err); hint != "" { fmt.Fprintln(stderr, hint) } return authError @@ -270,17 +270,6 @@ func Execute() (code exitCode) { return exitOK } -func authRemediation(err error) string { - if errors.Is(err, config.ErrAPIKeyMissingFromKeychain) { - return "" - } - if (errors.Is(err, config.ErrApplicationIDNotConfigured) || errors.Is(err, config.ErrAPIKeyNotConfigured)) && - auth.LoadToken() != nil { - return "You're signed in, but no application is selected.\nRun `algolia application select`, or pass --application-id." - } - return "Please run `algolia auth login` to get started." -} - // identifyNewlyAuthenticatedUser re-sends an Identify when the user signed in // during the command (e.g. `application create` while logged out): the // Identify from PersistentPreRunE went out anonymous, so the identity would diff --git a/pkg/config/resolution_test.go b/pkg/config/resolution_test.go index 1f9dd206..7559c35c 100644 --- a/pkg/config/resolution_test.go +++ b/pkg/config/resolution_test.go @@ -385,6 +385,38 @@ func TestShouldUseSessionAPIKey(t *testing.T) { assert.False(t, ShouldUseSessionAPIKey(cfg)) }) + t.Run("legacy config.toml profile with only an admin api key", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + loadConfigToml( + t, + "[legacy]\napplication_id = \"LEGACY_APP\"\nadmin_api_key = \"adm\"\ndefault = true\n", + ) + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("profile flag selecting a config.toml profile with a key", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + loadConfigToml( + t, + "[other]\napplication_id = \"OTHER_APP\"\napi_key = \"other\"\n\n[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy\"\ndefault = true\n", + ) + cfg.CurrentProfile.Name = "other" + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("profile flag selecting a config.toml profile without a key", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + loadConfigToml( + t, + "[other]\napplication_id = \"OTHER_APP\"\n\n[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy\"\ndefault = true\n", + ) + cfg.CurrentProfile.Name = "other" + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + t.Run("ALGOLIA_API_KEY set", func(t *testing.T) { cfg := newConfig(t, writeState(t, managedState)) t.Setenv("ALGOLIA_API_KEY", "env-key") From 1e3bb8559904a73017050da6b0c905358d23e5ea Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 13:59:16 -0700 Subject: [PATCH 3/7] test(apikeys): drop the create fix tests for a follow-up --- pkg/auth/auth_check_test.go | 49 ----- pkg/auth/authenticate_test.go | 20 -- pkg/cmd/apikeys/create/create_test.go | 260 ++------------------------ pkg/config/resolution_test.go | 32 ---- 4 files changed, 13 insertions(+), 348 deletions(-) diff --git a/pkg/auth/auth_check_test.go b/pkg/auth/auth_check_test.go index fac8075a..90b584f3 100644 --- a/pkg/auth/auth_check_test.go +++ b/pkg/auth/auth_check_test.go @@ -3,64 +3,15 @@ package auth import ( "os" "testing" - "time" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" - "github.com/algolia/cli/api/dashboard" - "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/httpmock" "github.com/algolia/cli/test" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zalando/go-keyring" ) -func TestRemediation(t *testing.T) { - t.Run("signed out", func(t *testing.T) { - keyring.MockInit() - - assert.Contains( - t, - Remediation(config.ErrApplicationIDNotConfigured), - "algolia auth login", - ) - }) - - t.Run("signed in", func(t *testing.T) { - keyring.MockInit() - require.NoError(t, SaveToken(&dashboard.OAuthTokenResponse{ - AccessToken: "tok-1", - ExpiresIn: 3600, - CreatedAt: time.Now().Unix(), - })) - - assert.Contains( - t, - Remediation(config.ErrAPIKeyNotConfigured), - "algolia application select", - ) - }) - - t.Run("api key missing from the keychain", func(t *testing.T) { - keyring.MockInit() - - assert.Empty(t, Remediation(config.ErrAPIKeyMissingFromKeychain)) - }) -} - -func TestWithRemediation(t *testing.T) { - keyring.MockInit() - - wrapped := WithRemediation(config.ErrAPIKeyNotConfigured) - require.ErrorIs(t, wrapped, config.ErrAPIKeyNotConfigured) - assert.Contains(t, wrapped.Error(), "algolia auth login") - - other := assert.AnError - assert.Same(t, other, WithRemediation(other)) -} - func Test_CheckACLs(t *testing.T) { // Remove these environment variables before the tests os.Unsetenv("ALGOLIA_APPLICATION_ID") diff --git a/pkg/auth/authenticate_test.go b/pkg/auth/authenticate_test.go index bc5c5e93..f04f7b3f 100644 --- a/pkg/auth/authenticate_test.go +++ b/pkg/auth/authenticate_test.go @@ -2,7 +2,6 @@ package auth import ( "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,25 +41,6 @@ func TestReauthenticateIfExpired_WithoutATerminalDoesNotStartTheOAuthFlow(t *tes assert.Contains(t, stderr.String(), "Session expired") } -func TestReauthenticateIfExpired_WithoutATerminalKeepsTheStoredToken(t *testing.T) { - keyring.MockInit() - require.NoError(t, SaveToken(&dashboard.OAuthTokenResponse{ - AccessToken: "tok-1", - ExpiresIn: 3600, - CreatedAt: time.Now().Unix(), - })) - - io, _, _, _ := iostreams.Test() - require.False(t, io.CanPrompt()) - - _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), dashboard.ErrSessionExpired) - require.Error(t, err) - - stored := LoadToken() - require.NotNil(t, stored) - assert.Equal(t, "tok-1", stored.AccessToken) -} - func TestReauthenticateIfExpired_PassesThroughOtherErrors(t *testing.T) { io, _, stdout, stderr := iostreams.Test() diff --git a/pkg/cmd/apikeys/create/create_test.go b/pkg/cmd/apikeys/create/create_test.go index 0f1faeb0..1c9527ab 100644 --- a/pkg/cmd/apikeys/create/create_test.go +++ b/pkg/cmd/apikeys/create/create_test.go @@ -30,12 +30,6 @@ const ( unroutableAPIURL = "http://127.0.0.1:1" ) -type ttys struct { - stdin bool - stdout bool - stderr bool -} - func withoutSession(t *testing.T) { t.Helper() t.Setenv("ALGOLIA_API_KEY", "") @@ -133,14 +127,12 @@ func unusedDashboardClient(t *testing.T) func(string) *dashboard.Client { func newDashboardOpts( t *testing.T, srv *httptest.Server, - tty ttys, -) (*CreateOptions, *bytes.Buffer, *bytes.Buffer) { + isTTY bool, +) (*CreateOptions, *bytes.Buffer) { t.Helper() - io, _, stdout, stderr := iostreams.Test() - io.SetStdinTTY(tty.stdin) - io.SetStdoutTTY(tty.stdout) - io.SetStderrTTY(tty.stderr) + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(isTTY) opts := &CreateOptions{ IO: io, @@ -152,7 +144,7 @@ func newDashboardOpts( }, PrintFlags: cmdutil.NewPrintFlags(), } - return opts, stdout, stderr + return opts, stdout } func TestNewCreateCmd(t *testing.T) { @@ -327,7 +319,7 @@ func Test_runCreateCmd_WithSession(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, stdout, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts, stdout := newDashboardOpts(t, srv, true) opts.ACL = []string{"search", "browse"} opts.Indices = []string{"MOVIES"} opts.Referers = []string{"https://example.com"} @@ -348,7 +340,7 @@ func Test_runCreateCmd_WithSessionNoTTYPrintsTheBareKey(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts, stdout := newDashboardOpts(t, srv, false) opts.ACL = []string{"search"} require.NoError(t, runCreateCmd(opts)) @@ -363,7 +355,7 @@ func Test_runCreateCmd_WithSessionKeepsTheGivenDescription(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts, _ := newDashboardOpts(t, srv, true) opts.ACL = []string{"search"} opts.Description = "frontend search key" @@ -379,7 +371,7 @@ func Test_runCreateCmd_WithSessionStructuredOutput(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts, stdout := newDashboardOpts(t, srv, false) opts.ACL = []string{"search"} format := "json" opts.PrintFlags.OutputFormat = &format @@ -417,7 +409,7 @@ func Test_runCreateCmd_WithSessionUnknownApplication(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts, _ := newDashboardOpts(t, srv, true) opts.ACL = []string{"search"} err := runCreateCmd(opts) @@ -456,7 +448,7 @@ func Test_runCreateCmd_WithSessionValidationErrors(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts, _ := newDashboardOpts(t, srv, true) tt.opts(opts) err := runCreateCmd(opts) @@ -473,7 +465,7 @@ func Test_runCreateCmd_SignedInWithoutAnApplication(t *testing.T) { srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts, _ := newDashboardOpts(t, srv, true) opts.ACL = []string{"search"} opts.config = &test.ConfigStub{} @@ -490,7 +482,7 @@ func Test_runCreateCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testi srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) defer srv.Close() - opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts, stdout := newDashboardOpts(t, srv, false) opts.ACL = []string{"search"} opts.config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} @@ -500,232 +492,6 @@ func Test_runCreateCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testi assert.Equal(t, createdValue+"\n", stdout.String()) } -func Test_runCreateCmd_ACLIsRequiredOnTheSearchAPIPath(t *testing.T) { - withoutSession(t) - - r := httpmock.Registry{} - r.Register(httpmock.REST("POST", "1/keys"), func(*http.Request) (*http.Response, error) { - t.Error("no key must be created without --acl") - return nil, errors.New("unexpected request") - }) - - f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewCreateCmd(f, nil) - _, err := test.Execute(cmd, "", out) - require.Error(t, err) - assert.Contains(t, err.Error(), "--acl is required") -} - -func Test_runCreateCmd_SessionStructuredOutputExposesTheKey(t *testing.T) { - tests := []struct { - name string - format string - template string - want string - }{ - { - name: "json", - format: "json", - want: `"key":"` + createdValue + `"`, - }, - { - name: "jsonpath", - format: "jsonpath", - template: "{.key}", - want: createdValue + "\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - withSession(t) - - var got dashboard.CreateAPIKeyRequest - srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) - defer srv.Close() - - opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) - opts.ACL = []string{"search"} - format := tt.format - opts.PrintFlags.OutputFormat = &format - if tt.template != "" { - template := tt.template - opts.PrintFlags.JSONPathPrintFlags.TemplateArgument = &template - } - - require.NoError(t, runCreateCmd(opts)) - assert.Contains(t, stdout.String(), tt.want) - }) - } -} - -func Test_runCreateCmd_SearchAPIStructuredOutputExposesTheKey(t *testing.T) { - tests := []struct { - name string - cli string - want string - }{ - { - name: "json", - cli: "--acl search -o json", - want: `"key":"foo"`, - }, - { - name: "jsonpath", - cli: "--acl search -o jsonpath --template {.key}", - want: "foo\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - withoutSession(t) - - r := httpmock.Registry{} - r.Register( - httpmock.REST("POST", "1/keys"), - httpmock.JSONResponse(search.AddApiKeyResponse{Key: "foo"}), - ) - - f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewCreateCmd(f, nil) - out, err := test.Execute(cmd, tt.cli, out) - require.NoError(t, err) - - assert.Contains(t, out.String(), tt.want) - }) - } -} - -func Test_runCreateCmd_UnsupportedOutputFormatFailsBeforeCreating(t *testing.T) { - t.Run("session path", func(t *testing.T) { - withSession(t) - - srv := httptest.NewServer( - http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - t.Errorf("no key must be created: %s %s", r.Method, r.URL.Path) - }), - ) - defer srv.Close() - - opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) - opts.ACL = []string{"search"} - format := "xml" - opts.PrintFlags.OutputFormat = &format - - err := runCreateCmd(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) - - r := httpmock.Registry{} - r.Register(httpmock.REST("POST", "1/keys"), func(*http.Request) (*http.Response, error) { - t.Error("no key must be created") - return nil, errors.New("unexpected request") - }) - - f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") - cmd := NewCreateCmd(f, nil) - _, err := test.Execute(cmd, "--acl search -o xml", out) - require.Error(t, err) - assert.Contains(t, err.Error(), "unable to match a printer") - assert.Empty(t, out.String()) - }) -} - -func Test_runCreateCmd_WithoutASessionOrAnApplication(t *testing.T) { - withoutSession(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, _ := newDashboardOpts(t, srv, ttys{}) - opts.ACL = []string{"search"} - opts.config = &test.ConfigStub{} - - err := runCreateCmd(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_runCreateCmd_WithoutASessionStdoutPipedStderrTTY(t *testing.T) { - withoutSession(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 := newDashboardOpts(t, srv, ttys{stdin: true, stderr: true}) - opts.ACL = []string{"search"} - require.False(t, opts.IO.CanPrompt()) - - err := runCreateCmd(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, _, _ := newDashboardOpts(t, srv, ttys{stdin: true, stdout: true, stderr: true}) - assert.True(t, prompting.IO.CanPrompt()) -} - -func Test_runCreateCmd_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") - - io, _, stdout, _ := iostreams.Test() - opts := &CreateOptions{ - IO: io, - config: &test.ConfigStub{}, - ACL: []string{"search"}, - SearchClient: func() (*search.APIClient, error) { - return nil, config.ErrApplicationIDNotConfigured - }, - NewDashboardClient: unusedDashboardClient(t), - PrintFlags: cmdutil.NewPrintFlags(), - } - - err := runCreateCmd(opts) - require.Error(t, err) - assert.ErrorIs(t, err, config.ErrApplicationIDNotConfigured) - assert.Contains(t, err.Error(), tt.want) - assert.Empty(t, stdout.String()) - }) - } -} - func Test_searchAPICreateError(t *testing.T) { forbidden := &search.APIError{Status: http.StatusForbidden, Message: "Not enough rights"} diff --git a/pkg/config/resolution_test.go b/pkg/config/resolution_test.go index 7559c35c..1f9dd206 100644 --- a/pkg/config/resolution_test.go +++ b/pkg/config/resolution_test.go @@ -385,38 +385,6 @@ func TestShouldUseSessionAPIKey(t *testing.T) { assert.False(t, ShouldUseSessionAPIKey(cfg)) }) - t.Run("legacy config.toml profile with only an admin api key", func(t *testing.T) { - cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) - loadConfigToml( - t, - "[legacy]\napplication_id = \"LEGACY_APP\"\nadmin_api_key = \"adm\"\ndefault = true\n", - ) - - assert.False(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("profile flag selecting a config.toml profile with a key", func(t *testing.T) { - cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) - loadConfigToml( - t, - "[other]\napplication_id = \"OTHER_APP\"\napi_key = \"other\"\n\n[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy\"\ndefault = true\n", - ) - cfg.CurrentProfile.Name = "other" - - assert.False(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("profile flag selecting a config.toml profile without a key", func(t *testing.T) { - cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) - loadConfigToml( - t, - "[other]\napplication_id = \"OTHER_APP\"\n\n[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy\"\ndefault = true\n", - ) - cfg.CurrentProfile.Name = "other" - - assert.True(t, ShouldUseSessionAPIKey(cfg)) - }) - t.Run("ALGOLIA_API_KEY set", func(t *testing.T) { cfg := newConfig(t, writeState(t, managedState)) t.Setenv("ALGOLIA_API_KEY", "env-key") From a9ad512eb2dc3f315268245477dff5fdaac7f953 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 14:14:12 -0700 Subject: [PATCH 4/7] test(apikeys): remove the stack's create tests entirely --- api/dashboard/client_test.go | 82 ------ pkg/auth/authenticate_test.go | 52 ---- pkg/cmd/apikeys/create/create_test.go | 403 +------------------------- pkg/config/resolution_test.go | 109 ------- 4 files changed, 5 insertions(+), 641 deletions(-) delete mode 100644 pkg/auth/authenticate_test.go diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index b3315cfe..bded1e2f 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -338,88 +338,6 @@ func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "no key was returned") } -func TestCreateAPIKeyWithParams_SendsAllParamsAndReturnsTheKey(t *testing.T) { - var got CreateAPIKeyRequest - - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) - - w.WriteHeader(http.StatusCreated) - require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{ - Data: APIKeyResource{ - ID: "key-uuid-123", - Type: "api_key", - Attributes: APIKeyAttributes{ - Value: "secret-key", - ApplicationID: "APP1", - ACL: []string{"search"}, - Description: "Search key", - Indexes: []string{"MOVIES"}, - Referers: []string{"https://example.com"}, - }, - }, - })) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - key, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ - ACL: []string{"search"}, - Description: "Search key", - Indexes: []string{"MOVIES"}, - Referers: []string{"https://example.com"}, - }) - require.NoError(t, err) - - assert.Equal(t, []string{"search"}, got.ACL) - assert.Equal(t, "Search key", got.Description) - assert.Equal(t, []string{"MOVIES"}, got.Indexes) - assert.Equal(t, []string{"https://example.com"}, got.Referers) - - assert.Equal(t, "key-uuid-123", key.UUID) - assert.Equal(t, "secret-key", key.Value) - assert.Equal(t, "APP1", key.ApplicationID) - assert.Equal(t, []string{"search"}, key.ACL) - assert.Equal(t, []string{"MOVIES"}, key.Indexes) -} - -func TestCreateAPIKeyWithParams_Unauthorized(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ - ACL: []string{"search"}, - Description: "Search key", - }) - require.ErrorIs(t, err, ErrSessionExpired) -} - -func TestCreateAPIKeyWithParams_ApplicationNotFound(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"errors":[{"status":"404","title":"Not Found"}]}`)) - }) - - ts, client := newTestClient(mux) - defer ts.Close() - - _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ - ACL: []string{"search"}, - Description: "Search key", - }) - require.ErrorIs(t, err, ErrApplicationNotFound) -} - func TestRotateAPIKey_ReturnsNewValue(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc( diff --git a/pkg/auth/authenticate_test.go b/pkg/auth/authenticate_test.go deleted file mode 100644 index f04f7b3f..00000000 --- a/pkg/auth/authenticate_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package auth - -import ( - "testing" - - "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/iostreams" -) - -func TestEnsureAuthenticated_WithoutATerminalDoesNotStartTheOAuthFlow(t *testing.T) { - keyring.MockInit() - ClearToken() - - io, _, stdout, stderr := iostreams.Test() - require.False(t, io.CanPrompt()) - - _, err := EnsureAuthenticated(io, dashboard.NewClient("test")) - 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") -} - -func TestReauthenticateIfExpired_WithoutATerminalDoesNotStartTheOAuthFlow(t *testing.T) { - keyring.MockInit() - - io, _, stdout, stderr := iostreams.Test() - - _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), dashboard.ErrSessionExpired) - 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") -} - -func TestReauthenticateIfExpired_PassesThroughOtherErrors(t *testing.T) { - io, _, stdout, stderr := iostreams.Test() - - other := assert.AnError - _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), other) - assert.Same(t, other, err) - assert.Empty(t, stdout.String()) - assert.Empty(t, stderr.String()) -} diff --git a/pkg/cmd/apikeys/create/create_test.go b/pkg/cmd/apikeys/create/create_test.go index 1c9527ab..eda9cc31 100644 --- a/pkg/cmd/apikeys/create/create_test.go +++ b/pkg/cmd/apikeys/create/create_test.go @@ -1,11 +1,6 @@ package create import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" "testing" "time" @@ -15,8 +10,6 @@ import ( "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" @@ -24,11 +17,7 @@ import ( "github.com/algolia/cli/test" ) -const ( - createdValue = "new-value" - createdUUID = "uuid-123" - unroutableAPIURL = "http://127.0.0.1:1" -) +const unroutableAPIURL = "http://127.0.0.1:1" func withoutSession(t *testing.T) { t.Helper() @@ -39,112 +28,10 @@ func withoutSession(t *testing.T) { keyring.MockInit() } -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(), - })) -} - -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 createdKeyResponse() dashboard.CreateAPIKeyResponse { - maxHits := 42 - maxQueries := 1000 - queryParameters := "filters=visible:true" - - return dashboard.CreateAPIKeyResponse{ - Data: dashboard.APIKeyResource{ - ID: createdUUID, - Type: "api_key", - Attributes: dashboard.APIKeyAttributes{ - Value: createdValue, - ApplicationID: "APP1", - ACL: []string{"browse"}, - Description: "stored description", - Indexes: []string{"SERIES"}, - Referers: []string{"https://stored.example.com"}, - MaxHitsPerQuery: &maxHits, - MaxQueriesPerIPPerHour: &maxQueries, - QueryParameters: &queryParameters, - }, - }, - } -} - -// createKeyServer stubs the dashboard create endpoint at wantPath, recording the -// received payload. -func createKeyServer( - t *testing.T, - wantPath string, - got *dashboard.CreateAPIKeyRequest, -) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - mux.HandleFunc(wantPath, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - require.NoError(t, json.NewDecoder(r.Body).Decode(got)) - w.WriteHeader(http.StatusCreated) - require.NoError(t, json.NewEncoder(w).Encode(createdKeyResponse())) - }) - return httptest.NewServer(mux) -} - -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 newDashboardOpts( - t *testing.T, - srv *httptest.Server, - isTTY bool, -) (*CreateOptions, *bytes.Buffer) { - t.Helper() - - io, _, stdout, _ := iostreams.Test() - io.SetStdoutTTY(isTTY) - - opts := &CreateOptions{ - IO: io, - config: managedKeyConfig(), - NewDashboardClient: func(string) *dashboard.Client { - c := dashboard.NewClientWithHTTPClient("test", srv.Client()) - c.APIURL = srv.URL - return c - }, - PrintFlags: cmdutil.NewPrintFlags(), + return &test.ConfigStub{ + CurrentProfile: config.Profile{ApplicationID: "APP1", APIKey: "adm"}, } - return opts, stdout } func TestNewCreateCmd(t *testing.T) { @@ -213,15 +100,6 @@ func TestNewCreateCmd(t *testing.T) { } } -func TestNewCreateCmd_SkipsTheAdminACLCheck(t *testing.T) { - io, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: io} - cmd := NewCreateCmd(f, nil) - - assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) - assert.Empty(t, cmd.Annotations["acls"]) -} - func Test_runCreateCmd(t *testing.T) { tests := []struct { name string @@ -230,7 +108,7 @@ func Test_runCreateCmd(t *testing.T) { wantOut string }{ { - name: "no TTY prints the bare key", + name: "no TTY", cli: "--acl search", isTTY: false, wantOut: "foo\n", @@ -254,10 +132,7 @@ func Test_runCreateCmd(t *testing.T) { ) f, out := test.NewFactory(tt.isTTY, &r, explicitKeyConfig(), "") - cmd := NewCreateCmd(f, func(o *CreateOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runCreateCmd(o) - }) + cmd := NewCreateCmd(f, nil) out, err := test.Execute(cmd, tt.cli, out) if err != nil { t.Fatal(err) @@ -267,271 +142,3 @@ func Test_runCreateCmd(t *testing.T) { }) } } - -func Test_runCreateCmd_ExplicitAPIKeyUsesTheSearchAPI(t *testing.T) { - withSession(t) - - r := httpmock.Registry{} - r.Register( - httpmock.REST("POST", "1/keys"), - httpmock.JSONResponse(search.AddApiKeyResponse{Key: "from-sapi"}), - ) - - cfg := managedKeyConfig() - cfg.CurrentProfile.APIKey = "admin-key" - - f, out := test.NewFactory(true, &r, cfg, "") - cmd := NewCreateCmd(f, func(o *CreateOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runCreateCmd(o) - }) - out, err := test.Execute(cmd, "--acl search", out) - require.NoError(t, err) - - assert.Contains(t, out.String(), "from-sapi") -} - -func Test_runCreateCmd_EnvAPIKeyUsesTheSearchAPI(t *testing.T) { - withSession(t) - t.Setenv("ALGOLIA_API_KEY", "env-admin-key") - - r := httpmock.Registry{} - r.Register( - httpmock.REST("POST", "1/keys"), - httpmock.JSONResponse(search.AddApiKeyResponse{Key: "from-sapi"}), - ) - - f, out := test.NewFactory(true, &r, managedKeyConfig(), "") - cmd := NewCreateCmd(f, func(o *CreateOptions) error { - o.NewDashboardClient = unusedDashboardClient(t) - return runCreateCmd(o) - }) - out, err := test.Execute(cmd, "--acl search", out) - require.NoError(t, err) - - assert.Contains(t, out.String(), "from-sapi") -} - -func Test_runCreateCmd_WithSession(t *testing.T) { - withSession(t) - - var got dashboard.CreateAPIKeyRequest - srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) - defer srv.Close() - - opts, stdout := newDashboardOpts(t, srv, true) - opts.ACL = []string{"search", "browse"} - opts.Indices = []string{"MOVIES"} - opts.Referers = []string{"https://example.com"} - - require.NoError(t, runCreateCmd(opts)) - - assert.Equal(t, []string{"search", "browse"}, got.ACL) - assert.Equal(t, []string{"MOVIES"}, got.Indexes) - assert.Equal(t, []string{"https://example.com"}, got.Referers) - assert.Equal(t, defaultDescription, got.Description) - assert.Equal(t, "✓ API key created: "+createdValue+"\n", stdout.String()) -} - -func Test_runCreateCmd_WithSessionNoTTYPrintsTheBareKey(t *testing.T) { - withSession(t) - - var got dashboard.CreateAPIKeyRequest - srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) - defer srv.Close() - - opts, stdout := newDashboardOpts(t, srv, false) - opts.ACL = []string{"search"} - - require.NoError(t, runCreateCmd(opts)) - - assert.Equal(t, createdValue+"\n", stdout.String()) -} - -func Test_runCreateCmd_WithSessionKeepsTheGivenDescription(t *testing.T) { - withSession(t) - - var got dashboard.CreateAPIKeyRequest - srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) - defer srv.Close() - - opts, _ := newDashboardOpts(t, srv, true) - opts.ACL = []string{"search"} - opts.Description = "frontend search key" - - require.NoError(t, runCreateCmd(opts)) - - assert.Equal(t, "frontend search key", got.Description) -} - -func Test_runCreateCmd_WithSessionStructuredOutput(t *testing.T) { - withSession(t) - - var got dashboard.CreateAPIKeyRequest - srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) - defer srv.Close() - - opts, stdout := newDashboardOpts(t, srv, false) - opts.ACL = []string{"search"} - format := "json" - opts.PrintFlags.OutputFormat = &format - - require.NoError(t, runCreateCmd(opts)) - - var key dashboard.APIKey - require.NoError(t, json.Unmarshal(stdout.Bytes(), &key)) - assert.Equal(t, createdValue, key.Value) - assert.Equal(t, createdUUID, key.UUID) - assert.Equal(t, "APP1", key.ApplicationID) - assert.Equal(t, []string{"browse"}, key.ACL) - assert.Equal(t, "stored description", key.Description) - assert.Equal(t, []string{"SERIES"}, key.Indexes) - assert.Equal(t, []string{"https://stored.example.com"}, key.Referers) - require.NotNil(t, key.MaxHitsPerQuery) - assert.Equal(t, 42, *key.MaxHitsPerQuery) - require.NotNil(t, key.MaxQueriesPerIPPerHour) - assert.Equal(t, 1000, *key.MaxQueriesPerIPPerHour) - require.NotNil(t, key.QueryParameters) - assert.Equal(t, "filters=visible:true", *key.QueryParameters) -} - -func Test_runCreateCmd_WithSessionUnknownApplication(t *testing.T) { - withSession(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, _ := newDashboardOpts(t, srv, true) - opts.ACL = []string{"search"} - - err := runCreateCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "APP1") - assert.Contains(t, err.Error(), "doesn't have access to it") - assert.Contains(t, err.Error(), "algolia application select") -} - -func Test_runCreateCmd_WithSessionValidationErrors(t *testing.T) { - tests := []struct { - name string - opts func(*CreateOptions) - wantErr string - }{ - { - name: "no ACL", - opts: func(o *CreateOptions) {}, - wantErr: "--acl is required", - }, - { - name: "validity", - opts: func(o *CreateOptions) { - o.ACL = []string{"search"} - o.Validity = time.Hour - }, - wantErr: "--validity isn't supported with your signed-in session", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - withSession(t) - - var got dashboard.CreateAPIKeyRequest - srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) - defer srv.Close() - - opts, _ := newDashboardOpts(t, srv, true) - tt.opts(opts) - - err := runCreateCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - }) - } -} - -func Test_runCreateCmd_SignedInWithoutAnApplication(t *testing.T) { - withSession(t) - - var got dashboard.CreateAPIKeyRequest - srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) - defer srv.Close() - - opts, _ := newDashboardOpts(t, srv, true) - opts.ACL = []string{"search"} - opts.config = &test.ConfigStub{} - - err := runCreateCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "no application selected") - assert.Contains(t, err.Error(), "algolia application select") -} - -func Test_runCreateCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testing.T) { - withSession(t) - - var got dashboard.CreateAPIKeyRequest - srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) - defer srv.Close() - - opts, stdout := newDashboardOpts(t, srv, false) - opts.ACL = []string{"search"} - opts.config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} - - require.NoError(t, runCreateCmd(opts)) - - assert.Equal(t, []string{"search"}, got.ACL) - assert.Equal(t, createdValue+"\n", stdout.String()) -} - -func Test_searchAPICreateError(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 := &CreateOptions{IO: io, config: cfg} - - err := searchAPICreateError(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 := &CreateOptions{IO: io, config: managedKeyConfig()} - - err := searchAPICreateError(opts, forbidden) - require.Error(t, err) - assert.Contains(t, err.Error(), "algolia auth login") - assert.NotContains(t, err.Error(), "--api-key") - }) - - t.Run("non-403 errors pass through", func(t *testing.T) { - withoutSession(t) - - io, _, _, _ := iostreams.Test() - opts := &CreateOptions{IO: io, config: managedKeyConfig()} - - other := &search.APIError{Status: http.StatusBadRequest, Message: "nope"} - assert.Same(t, other, searchAPICreateError(opts, other)) - - plain := errors.New("boom") - assert.Same(t, plain, searchAPICreateError(opts, plain)) - }) -} diff --git a/pkg/config/resolution_test.go b/pkg/config/resolution_test.go index 1f9dd206..8ed9246f 100644 --- a/pkg/config/resolution_test.go +++ b/pkg/config/resolution_test.go @@ -316,115 +316,6 @@ func TestProfile_GetCrawlerUserID_FromState(t *testing.T) { assert.Equal(t, "crawler-user", userID) } -func TestShouldUseSessionAPIKey(t *testing.T) { - newConfig := func(t *testing.T, state string) *Config { - t.Helper() - t.Setenv("ALGOLIA_API_KEY", "") - t.Setenv("ALGOLIA_ADMIN_API_KEY", "") - t.Setenv("ALGOLIA_APPLICATION_ID", "") - keyring.MockInit() - viper.Reset() - t.Cleanup(viper.Reset) - - cfg := &Config{StateFile: state} - cfg.CurrentProfile.config = cfg - - return cfg - } - - writeState := func(t *testing.T, content string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "state.toml") - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - - return path - } - - loadConfigToml := func(t *testing.T, content string) { - t.Helper() - path := filepath.Join(t.TempDir(), "config.toml") - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - viper.SetConfigType("toml") - viper.SetConfigFile(path) - require.NoError(t, viper.ReadInConfig()) - } - - managedState := "current_application_id = \"MANAGED\"\n\n[applications.MANAGED]\nalias = \"prod\"\napi_key_uuid = \"uuid-1\"\n" - reusedState := "current_application_id = \"REUSED\"\n\n[applications.REUSED]\nalias = \"legacy\"\n" - - t.Run("active application with a stored UUID", func(t *testing.T) { - cfg := newConfig(t, writeState(t, managedState)) - require.NoError( - t, - keychain.SaveAppSecrets("MANAGED", keychain.AppSecrets{APIKey: "cli-key"}), - ) - - assert.True(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("application id flag with an empty state file", func(t *testing.T) { - cfg := newConfig(t, writeState(t, "")) - cfg.CurrentProfile.ApplicationID = "FLAG_APP" - - assert.True(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("nothing configured", func(t *testing.T) { - cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) - - assert.True(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("legacy config.toml profile with an api key", func(t *testing.T) { - cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) - loadConfigToml( - t, - "[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy-key\"\ndefault = true\n", - ) - - assert.False(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("ALGOLIA_API_KEY set", func(t *testing.T) { - cfg := newConfig(t, writeState(t, managedState)) - t.Setenv("ALGOLIA_API_KEY", "env-key") - - assert.False(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("api key flag set", func(t *testing.T) { - cfg := newConfig(t, writeState(t, managedState)) - cfg.CurrentProfile.APIKey = "flag-key" - - assert.False(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("reused keychain key without a UUID", func(t *testing.T) { - cfg := newConfig(t, writeState(t, reusedState)) - require.NoError( - t, - keychain.SaveAppSecrets("REUSED", keychain.AppSecrets{APIKey: "old-key"}), - ) - - assert.False(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("stored UUID but no key in the keychain", func(t *testing.T) { - cfg := newConfig(t, writeState(t, managedState)) - - assert.True(t, ShouldUseSessionAPIKey(cfg)) - }) - - t.Run("uninitialized config", func(t *testing.T) { - t.Setenv("ALGOLIA_API_KEY", "") - t.Setenv("ALGOLIA_APPLICATION_ID", "") - viper.Reset() - t.Cleanup(viper.Reset) - - assert.True(t, ShouldUseSessionAPIKey(&Config{})) - }) -} - func TestConfig_ApplicationInState(t *testing.T) { path := filepath.Join(t.TempDir(), "state.toml") require.NoError(t, os.WriteFile(path, []byte( From 3716f3c5a3a4d748b6d6bb6eb5410be6510553ed Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 27 Jul 2026 14:23:59 -0700 Subject: [PATCH 5/7] test(config): restore resolution_test.go to its main state --- pkg/config/resolution_test.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/pkg/config/resolution_test.go b/pkg/config/resolution_test.go index 8ed9246f..79f9a95a 100644 --- a/pkg/config/resolution_test.go +++ b/pkg/config/resolution_test.go @@ -3,7 +3,6 @@ package config import ( "os" "path/filepath" - "strings" "testing" "github.com/spf13/viper" @@ -14,16 +13,6 @@ import ( "github.com/algolia/cli/pkg/keychain" ) -func TestMain(m *testing.M) { - for _, entry := range os.Environ() { - if name, _, ok := strings.Cut(entry, "="); ok && strings.HasPrefix(name, "ALGOLIA_") { - _ = os.Unsetenv(name) - } - } - - os.Exit(m.Run()) -} - func TestConfig_LoadStateCachesAndToleratesMissing(t *testing.T) { cfg := &Config{StateFile: filepath.Join(t.TempDir(), "state.toml")} From b51cec534a975791f7ba5c80ec0640834e7d4f1d Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 28 Jul 2026 10:34:05 -0700 Subject: [PATCH 6/7] test(apikeys): restore the create stack test suite --- api/dashboard/client_test.go | 82 ++++ pkg/auth/auth_check_test.go | 49 ++ pkg/auth/authenticate_test.go | 72 +++ pkg/cmd/apikeys/create/create_test.go | 637 +++++++++++++++++++++++++- pkg/config/resolution_test.go | 152 ++++++ 5 files changed, 987 insertions(+), 5 deletions(-) create mode 100644 pkg/auth/authenticate_test.go diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index bded1e2f..b3315cfe 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -338,6 +338,88 @@ func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "no key was returned") } +func TestCreateAPIKeyWithParams_SendsAllParamsAndReturnsTheKey(t *testing.T) { + var got CreateAPIKeyRequest + + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{ + Data: APIKeyResource{ + ID: "key-uuid-123", + Type: "api_key", + Attributes: APIKeyAttributes{ + Value: "secret-key", + ApplicationID: "APP1", + ACL: []string{"search"}, + Description: "Search key", + Indexes: []string{"MOVIES"}, + Referers: []string{"https://example.com"}, + }, + }, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + key, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + Indexes: []string{"MOVIES"}, + Referers: []string{"https://example.com"}, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"search"}, got.ACL) + assert.Equal(t, "Search key", got.Description) + assert.Equal(t, []string{"MOVIES"}, got.Indexes) + assert.Equal(t, []string{"https://example.com"}, got.Referers) + + assert.Equal(t, "key-uuid-123", key.UUID) + assert.Equal(t, "secret-key", key.Value) + assert.Equal(t, "APP1", key.ApplicationID) + assert.Equal(t, []string{"search"}, key.ACL) + assert.Equal(t, []string{"MOVIES"}, key.Indexes) +} + +func TestCreateAPIKeyWithParams_Unauthorized(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + }) + require.ErrorIs(t, err, ErrSessionExpired) +} + +func TestCreateAPIKeyWithParams_ApplicationNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errors":[{"status":"404","title":"Not Found"}]}`)) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{ + ACL: []string{"search"}, + Description: "Search key", + }) + require.ErrorIs(t, err, ErrApplicationNotFound) +} + func TestRotateAPIKey_ReturnsNewValue(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc( diff --git a/pkg/auth/auth_check_test.go b/pkg/auth/auth_check_test.go index 90b584f3..fac8075a 100644 --- a/pkg/auth/auth_check_test.go +++ b/pkg/auth/auth_check_test.go @@ -3,15 +3,64 @@ package auth import ( "os" "testing" + "time" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/httpmock" "github.com/algolia/cli/test" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" ) +func TestRemediation(t *testing.T) { + t.Run("signed out", func(t *testing.T) { + keyring.MockInit() + + assert.Contains( + t, + Remediation(config.ErrApplicationIDNotConfigured), + "algolia auth login", + ) + }) + + t.Run("signed in", func(t *testing.T) { + keyring.MockInit() + require.NoError(t, SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "tok-1", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) + + assert.Contains( + t, + Remediation(config.ErrAPIKeyNotConfigured), + "algolia application select", + ) + }) + + t.Run("api key missing from the keychain", func(t *testing.T) { + keyring.MockInit() + + assert.Empty(t, Remediation(config.ErrAPIKeyMissingFromKeychain)) + }) +} + +func TestWithRemediation(t *testing.T) { + keyring.MockInit() + + wrapped := WithRemediation(config.ErrAPIKeyNotConfigured) + require.ErrorIs(t, wrapped, config.ErrAPIKeyNotConfigured) + assert.Contains(t, wrapped.Error(), "algolia auth login") + + other := assert.AnError + assert.Same(t, other, WithRemediation(other)) +} + func Test_CheckACLs(t *testing.T) { // Remove these environment variables before the tests os.Unsetenv("ALGOLIA_APPLICATION_ID") diff --git a/pkg/auth/authenticate_test.go b/pkg/auth/authenticate_test.go new file mode 100644 index 00000000..bc5c5e93 --- /dev/null +++ b/pkg/auth/authenticate_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "testing" + "time" + + "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/iostreams" +) + +func TestEnsureAuthenticated_WithoutATerminalDoesNotStartTheOAuthFlow(t *testing.T) { + keyring.MockInit() + ClearToken() + + io, _, stdout, stderr := iostreams.Test() + require.False(t, io.CanPrompt()) + + _, err := EnsureAuthenticated(io, dashboard.NewClient("test")) + 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") +} + +func TestReauthenticateIfExpired_WithoutATerminalDoesNotStartTheOAuthFlow(t *testing.T) { + keyring.MockInit() + + io, _, stdout, stderr := iostreams.Test() + + _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), dashboard.ErrSessionExpired) + 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") +} + +func TestReauthenticateIfExpired_WithoutATerminalKeepsTheStoredToken(t *testing.T) { + keyring.MockInit() + require.NoError(t, SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "tok-1", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) + + io, _, _, _ := iostreams.Test() + require.False(t, io.CanPrompt()) + + _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), dashboard.ErrSessionExpired) + require.Error(t, err) + + stored := LoadToken() + require.NotNil(t, stored) + assert.Equal(t, "tok-1", stored.AccessToken) +} + +func TestReauthenticateIfExpired_PassesThroughOtherErrors(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + + other := assert.AnError + _, err := ReauthenticateIfExpired(io, dashboard.NewClient("test"), other) + assert.Same(t, other, err) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} diff --git a/pkg/cmd/apikeys/create/create_test.go b/pkg/cmd/apikeys/create/create_test.go index eda9cc31..0f1faeb0 100644 --- a/pkg/cmd/apikeys/create/create_test.go +++ b/pkg/cmd/apikeys/create/create_test.go @@ -1,6 +1,11 @@ package create import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" "testing" "time" @@ -10,6 +15,8 @@ import ( "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" @@ -17,7 +24,17 @@ import ( "github.com/algolia/cli/test" ) -const unroutableAPIURL = "http://127.0.0.1:1" +const ( + createdValue = "new-value" + createdUUID = "uuid-123" + unroutableAPIURL = "http://127.0.0.1:1" +) + +type ttys struct { + stdin bool + stdout bool + stderr bool +} func withoutSession(t *testing.T) { t.Helper() @@ -28,12 +45,116 @@ func withoutSession(t *testing.T) { keyring.MockInit() } -func explicitKeyConfig() *test.ConfigStub { +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(), + })) +} + +func managedKeyConfig() *test.ConfigStub { return &test.ConfigStub{ - CurrentProfile: config.Profile{ApplicationID: "APP1", APIKey: "adm"}, + CurrentProfile: config.Profile{ApplicationID: "APP1"}, + ActiveAppID: "APP1", + SavedApps: map[string]test.SavedApplication{ + "APP1": {APIKeyUUID: "uuid-1", APIKey: "cli-key"}, + }, } } +func explicitKeyConfig() *test.ConfigStub { + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "adm" + + return cfg +} + +func createdKeyResponse() dashboard.CreateAPIKeyResponse { + maxHits := 42 + maxQueries := 1000 + queryParameters := "filters=visible:true" + + return dashboard.CreateAPIKeyResponse{ + Data: dashboard.APIKeyResource{ + ID: createdUUID, + Type: "api_key", + Attributes: dashboard.APIKeyAttributes{ + Value: createdValue, + ApplicationID: "APP1", + ACL: []string{"browse"}, + Description: "stored description", + Indexes: []string{"SERIES"}, + Referers: []string{"https://stored.example.com"}, + MaxHitsPerQuery: &maxHits, + MaxQueriesPerIPPerHour: &maxQueries, + QueryParameters: &queryParameters, + }, + }, + } +} + +// createKeyServer stubs the dashboard create endpoint at wantPath, recording the +// received payload. +func createKeyServer( + t *testing.T, + wantPath string, + got *dashboard.CreateAPIKeyRequest, +) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc(wantPath, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + require.NoError(t, json.NewDecoder(r.Body).Decode(got)) + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(createdKeyResponse())) + }) + return httptest.NewServer(mux) +} + +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 newDashboardOpts( + t *testing.T, + srv *httptest.Server, + tty ttys, +) (*CreateOptions, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(tty.stdin) + io.SetStdoutTTY(tty.stdout) + io.SetStderrTTY(tty.stderr) + + opts := &CreateOptions{ + IO: io, + config: managedKeyConfig(), + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + }, + PrintFlags: cmdutil.NewPrintFlags(), + } + return opts, stdout, stderr +} + func TestNewCreateCmd(t *testing.T) { oneHour, _ := time.ParseDuration("1h") @@ -100,6 +221,15 @@ func TestNewCreateCmd(t *testing.T) { } } +func TestNewCreateCmd_SkipsTheAdminACLCheck(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: io} + cmd := NewCreateCmd(f, nil) + + assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) + assert.Empty(t, cmd.Annotations["acls"]) +} + func Test_runCreateCmd(t *testing.T) { tests := []struct { name string @@ -108,7 +238,7 @@ func Test_runCreateCmd(t *testing.T) { wantOut string }{ { - name: "no TTY", + name: "no TTY prints the bare key", cli: "--acl search", isTTY: false, wantOut: "foo\n", @@ -132,7 +262,10 @@ func Test_runCreateCmd(t *testing.T) { ) f, out := test.NewFactory(tt.isTTY, &r, explicitKeyConfig(), "") - cmd := NewCreateCmd(f, nil) + cmd := NewCreateCmd(f, func(o *CreateOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runCreateCmd(o) + }) out, err := test.Execute(cmd, tt.cli, out) if err != nil { t.Fatal(err) @@ -142,3 +275,497 @@ func Test_runCreateCmd(t *testing.T) { }) } } + +func Test_runCreateCmd_ExplicitAPIKeyUsesTheSearchAPI(t *testing.T) { + withSession(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/keys"), + httpmock.JSONResponse(search.AddApiKeyResponse{Key: "from-sapi"}), + ) + + cfg := managedKeyConfig() + cfg.CurrentProfile.APIKey = "admin-key" + + f, out := test.NewFactory(true, &r, cfg, "") + cmd := NewCreateCmd(f, func(o *CreateOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runCreateCmd(o) + }) + out, err := test.Execute(cmd, "--acl search", out) + require.NoError(t, err) + + assert.Contains(t, out.String(), "from-sapi") +} + +func Test_runCreateCmd_EnvAPIKeyUsesTheSearchAPI(t *testing.T) { + withSession(t) + t.Setenv("ALGOLIA_API_KEY", "env-admin-key") + + r := httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/keys"), + httpmock.JSONResponse(search.AddApiKeyResponse{Key: "from-sapi"}), + ) + + f, out := test.NewFactory(true, &r, managedKeyConfig(), "") + cmd := NewCreateCmd(f, func(o *CreateOptions) error { + o.NewDashboardClient = unusedDashboardClient(t) + return runCreateCmd(o) + }) + out, err := test.Execute(cmd, "--acl search", out) + require.NoError(t, err) + + assert.Contains(t, out.String(), "from-sapi") +} + +func Test_runCreateCmd_WithSession(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts.ACL = []string{"search", "browse"} + opts.Indices = []string{"MOVIES"} + opts.Referers = []string{"https://example.com"} + + require.NoError(t, runCreateCmd(opts)) + + assert.Equal(t, []string{"search", "browse"}, got.ACL) + assert.Equal(t, []string{"MOVIES"}, got.Indexes) + assert.Equal(t, []string{"https://example.com"}, got.Referers) + assert.Equal(t, defaultDescription, got.Description) + assert.Equal(t, "✓ API key created: "+createdValue+"\n", stdout.String()) +} + +func Test_runCreateCmd_WithSessionNoTTYPrintsTheBareKey(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + + require.NoError(t, runCreateCmd(opts)) + + assert.Equal(t, createdValue+"\n", stdout.String()) +} + +func Test_runCreateCmd_WithSessionKeepsTheGivenDescription(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts.ACL = []string{"search"} + opts.Description = "frontend search key" + + require.NoError(t, runCreateCmd(opts)) + + assert.Equal(t, "frontend search key", got.Description) +} + +func Test_runCreateCmd_WithSessionStructuredOutput(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + format := "json" + opts.PrintFlags.OutputFormat = &format + + require.NoError(t, runCreateCmd(opts)) + + var key dashboard.APIKey + require.NoError(t, json.Unmarshal(stdout.Bytes(), &key)) + assert.Equal(t, createdValue, key.Value) + assert.Equal(t, createdUUID, key.UUID) + assert.Equal(t, "APP1", key.ApplicationID) + assert.Equal(t, []string{"browse"}, key.ACL) + assert.Equal(t, "stored description", key.Description) + assert.Equal(t, []string{"SERIES"}, key.Indexes) + assert.Equal(t, []string{"https://stored.example.com"}, key.Referers) + require.NotNil(t, key.MaxHitsPerQuery) + assert.Equal(t, 42, *key.MaxHitsPerQuery) + require.NotNil(t, key.MaxQueriesPerIPPerHour) + assert.Equal(t, 1000, *key.MaxQueriesPerIPPerHour) + require.NotNil(t, key.QueryParameters) + assert.Equal(t, "filters=visible:true", *key.QueryParameters) +} + +func Test_runCreateCmd_WithSessionUnknownApplication(t *testing.T) { + withSession(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, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts.ACL = []string{"search"} + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "APP1") + assert.Contains(t, err.Error(), "doesn't have access to it") + assert.Contains(t, err.Error(), "algolia application select") +} + +func Test_runCreateCmd_WithSessionValidationErrors(t *testing.T) { + tests := []struct { + name string + opts func(*CreateOptions) + wantErr string + }{ + { + name: "no ACL", + opts: func(o *CreateOptions) {}, + wantErr: "--acl is required", + }, + { + name: "validity", + opts: func(o *CreateOptions) { + o.ACL = []string{"search"} + o.Validity = time.Hour + }, + wantErr: "--validity isn't supported with your signed-in session", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + tt.opts(opts) + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func Test_runCreateCmd_SignedInWithoutAnApplication(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, _, _ := newDashboardOpts(t, srv, ttys{stdout: true, stderr: true}) + opts.ACL = []string{"search"} + opts.config = &test.ConfigStub{} + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no application selected") + assert.Contains(t, err.Error(), "algolia application select") +} + +func Test_runCreateCmd_ApplicationIDFlagWithoutAStoredKeyUsesTheSession(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + opts.config = &test.ConfigStub{CurrentProfile: config.Profile{ApplicationID: "APP1"}} + + require.NoError(t, runCreateCmd(opts)) + + assert.Equal(t, []string{"search"}, got.ACL) + assert.Equal(t, createdValue+"\n", stdout.String()) +} + +func Test_runCreateCmd_ACLIsRequiredOnTheSearchAPIPath(t *testing.T) { + withoutSession(t) + + r := httpmock.Registry{} + r.Register(httpmock.REST("POST", "1/keys"), func(*http.Request) (*http.Response, error) { + t.Error("no key must be created without --acl") + return nil, errors.New("unexpected request") + }) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewCreateCmd(f, nil) + _, err := test.Execute(cmd, "", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--acl is required") +} + +func Test_runCreateCmd_SessionStructuredOutputExposesTheKey(t *testing.T) { + tests := []struct { + name string + format string + template string + want string + }{ + { + name: "json", + format: "json", + want: `"key":"` + createdValue + `"`, + }, + { + name: "jsonpath", + format: "jsonpath", + template: "{.key}", + want: createdValue + "\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withSession(t) + + var got dashboard.CreateAPIKeyRequest + srv := createKeyServer(t, "/1/applications/APP1/api-keys", &got) + defer srv.Close() + + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + format := tt.format + opts.PrintFlags.OutputFormat = &format + if tt.template != "" { + template := tt.template + opts.PrintFlags.JSONPathPrintFlags.TemplateArgument = &template + } + + require.NoError(t, runCreateCmd(opts)) + assert.Contains(t, stdout.String(), tt.want) + }) + } +} + +func Test_runCreateCmd_SearchAPIStructuredOutputExposesTheKey(t *testing.T) { + tests := []struct { + name string + cli string + want string + }{ + { + name: "json", + cli: "--acl search -o json", + want: `"key":"foo"`, + }, + { + name: "jsonpath", + cli: "--acl search -o jsonpath --template {.key}", + want: "foo\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withoutSession(t) + + r := httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/keys"), + httpmock.JSONResponse(search.AddApiKeyResponse{Key: "foo"}), + ) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewCreateCmd(f, nil) + out, err := test.Execute(cmd, tt.cli, out) + require.NoError(t, err) + + assert.Contains(t, out.String(), tt.want) + }) + } +} + +func Test_runCreateCmd_UnsupportedOutputFormatFailsBeforeCreating(t *testing.T) { + t.Run("session path", func(t *testing.T) { + withSession(t) + + srv := httptest.NewServer( + http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("no key must be created: %s %s", r.Method, r.URL.Path) + }), + ) + defer srv.Close() + + opts, stdout, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + format := "xml" + opts.PrintFlags.OutputFormat = &format + + err := runCreateCmd(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) + + r := httpmock.Registry{} + r.Register(httpmock.REST("POST", "1/keys"), func(*http.Request) (*http.Response, error) { + t.Error("no key must be created") + return nil, errors.New("unexpected request") + }) + + f, out := test.NewFactory(false, &r, explicitKeyConfig(), "") + cmd := NewCreateCmd(f, nil) + _, err := test.Execute(cmd, "--acl search -o xml", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to match a printer") + assert.Empty(t, out.String()) + }) +} + +func Test_runCreateCmd_WithoutASessionOrAnApplication(t *testing.T) { + withoutSession(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, _ := newDashboardOpts(t, srv, ttys{}) + opts.ACL = []string{"search"} + opts.config = &test.ConfigStub{} + + err := runCreateCmd(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_runCreateCmd_WithoutASessionStdoutPipedStderrTTY(t *testing.T) { + withoutSession(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 := newDashboardOpts(t, srv, ttys{stdin: true, stderr: true}) + opts.ACL = []string{"search"} + require.False(t, opts.IO.CanPrompt()) + + err := runCreateCmd(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, _, _ := newDashboardOpts(t, srv, ttys{stdin: true, stdout: true, stderr: true}) + assert.True(t, prompting.IO.CanPrompt()) +} + +func Test_runCreateCmd_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") + + io, _, stdout, _ := iostreams.Test() + opts := &CreateOptions{ + IO: io, + config: &test.ConfigStub{}, + ACL: []string{"search"}, + SearchClient: func() (*search.APIClient, error) { + return nil, config.ErrApplicationIDNotConfigured + }, + NewDashboardClient: unusedDashboardClient(t), + PrintFlags: cmdutil.NewPrintFlags(), + } + + err := runCreateCmd(opts) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrApplicationIDNotConfigured) + assert.Contains(t, err.Error(), tt.want) + assert.Empty(t, stdout.String()) + }) + } +} + +func Test_searchAPICreateError(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 := &CreateOptions{IO: io, config: cfg} + + err := searchAPICreateError(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 := &CreateOptions{IO: io, config: managedKeyConfig()} + + err := searchAPICreateError(opts, forbidden) + require.Error(t, err) + assert.Contains(t, err.Error(), "algolia auth login") + assert.NotContains(t, err.Error(), "--api-key") + }) + + t.Run("non-403 errors pass through", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + opts := &CreateOptions{IO: io, config: managedKeyConfig()} + + other := &search.APIError{Status: http.StatusBadRequest, Message: "nope"} + assert.Same(t, other, searchAPICreateError(opts, other)) + + plain := errors.New("boom") + assert.Same(t, plain, searchAPICreateError(opts, plain)) + }) +} diff --git a/pkg/config/resolution_test.go b/pkg/config/resolution_test.go index 79f9a95a..7559c35c 100644 --- a/pkg/config/resolution_test.go +++ b/pkg/config/resolution_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "github.com/spf13/viper" @@ -13,6 +14,16 @@ import ( "github.com/algolia/cli/pkg/keychain" ) +func TestMain(m *testing.M) { + for _, entry := range os.Environ() { + if name, _, ok := strings.Cut(entry, "="); ok && strings.HasPrefix(name, "ALGOLIA_") { + _ = os.Unsetenv(name) + } + } + + os.Exit(m.Run()) +} + func TestConfig_LoadStateCachesAndToleratesMissing(t *testing.T) { cfg := &Config{StateFile: filepath.Join(t.TempDir(), "state.toml")} @@ -305,6 +316,147 @@ func TestProfile_GetCrawlerUserID_FromState(t *testing.T) { assert.Equal(t, "crawler-user", userID) } +func TestShouldUseSessionAPIKey(t *testing.T) { + newConfig := func(t *testing.T, state string) *Config { + t.Helper() + t.Setenv("ALGOLIA_API_KEY", "") + t.Setenv("ALGOLIA_ADMIN_API_KEY", "") + t.Setenv("ALGOLIA_APPLICATION_ID", "") + keyring.MockInit() + viper.Reset() + t.Cleanup(viper.Reset) + + cfg := &Config{StateFile: state} + cfg.CurrentProfile.config = cfg + + return cfg + } + + writeState := func(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "state.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + return path + } + + loadConfigToml := func(t *testing.T, content string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + viper.SetConfigType("toml") + viper.SetConfigFile(path) + require.NoError(t, viper.ReadInConfig()) + } + + managedState := "current_application_id = \"MANAGED\"\n\n[applications.MANAGED]\nalias = \"prod\"\napi_key_uuid = \"uuid-1\"\n" + reusedState := "current_application_id = \"REUSED\"\n\n[applications.REUSED]\nalias = \"legacy\"\n" + + t.Run("active application with a stored UUID", func(t *testing.T) { + cfg := newConfig(t, writeState(t, managedState)) + require.NoError( + t, + keychain.SaveAppSecrets("MANAGED", keychain.AppSecrets{APIKey: "cli-key"}), + ) + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("application id flag with an empty state file", func(t *testing.T) { + cfg := newConfig(t, writeState(t, "")) + cfg.CurrentProfile.ApplicationID = "FLAG_APP" + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("nothing configured", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("legacy config.toml profile with an api key", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + loadConfigToml( + t, + "[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy-key\"\ndefault = true\n", + ) + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("legacy config.toml profile with only an admin api key", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + loadConfigToml( + t, + "[legacy]\napplication_id = \"LEGACY_APP\"\nadmin_api_key = \"adm\"\ndefault = true\n", + ) + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("profile flag selecting a config.toml profile with a key", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + loadConfigToml( + t, + "[other]\napplication_id = \"OTHER_APP\"\napi_key = \"other\"\n\n[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy\"\ndefault = true\n", + ) + cfg.CurrentProfile.Name = "other" + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("profile flag selecting a config.toml profile without a key", func(t *testing.T) { + cfg := newConfig(t, filepath.Join(t.TempDir(), "absent.toml")) + loadConfigToml( + t, + "[other]\napplication_id = \"OTHER_APP\"\n\n[legacy]\napplication_id = \"LEGACY_APP\"\napi_key = \"legacy\"\ndefault = true\n", + ) + cfg.CurrentProfile.Name = "other" + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("ALGOLIA_API_KEY set", func(t *testing.T) { + cfg := newConfig(t, writeState(t, managedState)) + t.Setenv("ALGOLIA_API_KEY", "env-key") + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("api key flag set", func(t *testing.T) { + cfg := newConfig(t, writeState(t, managedState)) + cfg.CurrentProfile.APIKey = "flag-key" + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("reused keychain key without a UUID", func(t *testing.T) { + cfg := newConfig(t, writeState(t, reusedState)) + require.NoError( + t, + keychain.SaveAppSecrets("REUSED", keychain.AppSecrets{APIKey: "old-key"}), + ) + + assert.False(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("stored UUID but no key in the keychain", func(t *testing.T) { + cfg := newConfig(t, writeState(t, managedState)) + + assert.True(t, ShouldUseSessionAPIKey(cfg)) + }) + + t.Run("uninitialized config", func(t *testing.T) { + t.Setenv("ALGOLIA_API_KEY", "") + t.Setenv("ALGOLIA_APPLICATION_ID", "") + viper.Reset() + t.Cleanup(viper.Reset) + + assert.True(t, ShouldUseSessionAPIKey(&Config{})) + }) +} + func TestConfig_ApplicationInState(t *testing.T) { path := filepath.Join(t.TempDir(), "state.toml") require.NoError(t, os.WriteFile(path, []byte( From 1f40fe1ad39f18f71613102d09e7404880a8b725 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 28 Jul 2026 10:35:35 -0700 Subject: [PATCH 7/7] fix(apikeys): drop the unreachable create 403 branch --- pkg/cmd/apikeys/create/create.go | 7 ------- pkg/cmd/apikeys/create/create_test.go | 5 +++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/apikeys/create/create.go b/pkg/cmd/apikeys/create/create.go index 1872ec73..443960d0 100644 --- a/pkg/cmd/apikeys/create/create.go +++ b/pkg/cmd/apikeys/create/create.go @@ -346,13 +346,6 @@ func searchAPICreateError(opts *CreateOptions, err error) error { } cs := opts.IO.ColorScheme() - if config.ShouldUseSessionAPIKey(opts.config) { - return fmt.Errorf( - "%w\nRun %s to create API keys without an admin key", - err, - cs.Bold("algolia auth login"), - ) - } return fmt.Errorf( "%w\nThe API key in use isn't an admin key. Provide an admin key, or drop the key set through %s, %s or your profile to create the key with your signed-in session", diff --git a/pkg/cmd/apikeys/create/create_test.go b/pkg/cmd/apikeys/create/create_test.go index 0f1faeb0..e3bbf86a 100644 --- a/pkg/cmd/apikeys/create/create_test.go +++ b/pkg/cmd/apikeys/create/create_test.go @@ -752,8 +752,9 @@ func Test_searchAPICreateError(t *testing.T) { err := searchAPICreateError(opts, forbidden) require.Error(t, err) - assert.Contains(t, err.Error(), "algolia auth login") - assert.NotContains(t, err.Error(), "--api-key") + assert.Contains(t, err.Error(), "isn't an admin key") + assert.Contains(t, err.Error(), "ALGOLIA_API_KEY") + assert.NotContains(t, err.Error(), "algolia auth login") }) t.Run("non-403 errors pass through", func(t *testing.T) {