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/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 3ca2f711..a44108dc 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. @@ -40,8 +47,16 @@ func ReauthenticateIfExpired( } cs := io.ColorScheme() + 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"), + ) + } + ClearToken() - fmt.Fprintf(io.Out, "%s Session expired.\n", cs.WarningIcon()) // 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..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.go b/pkg/cmd/apikeys/create/create.go index 0576b64e..443960d0 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/printers" "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 ACL []string Description string @@ -37,23 +45,44 @@ 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) + }, + 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. + + 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 + $ 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 +119,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 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(` @@ -148,6 +178,136 @@ func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co // runCreateCmd executes the create command func runCreateCmd(opts *CreateOptions) error { + if len(opts.ACL) == 0 { + return fmt.Errorf( + "--acl is required, for example %s", + opts.IO.ColorScheme().Bold("algolia apikeys create --acl search"), + ) + } + + 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 opts.Validity != 0 { + return fmt.Errorf( + "--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( + "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 printer != nil { + return printer.Print(opts.IO, createdKeyOutput{APIKey: key, Key: key.Value}) + } + + printCreatedKey(opts.IO, key.Value) + + 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) + 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 { + printer, err := structuredPrinter(opts) + if err != nil { + return err + } + var acls []search.Acl for _, a := range opts.ACL { acls = append(acls, search.Acl(a)) @@ -163,24 +323,34 @@ func runCreateCmd(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 err + return searchAPICreateError(opts, err) } - if opts.PrintFlags.OutputFlagSpecified() && opts.PrintFlags.OutputFormat != nil { - 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) } - cs := opts.IO.ColorScheme() - if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "%s API key created: %s\n", cs.SuccessIcon(), res.Key) - } + 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() + + 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..e3bbf86a 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,148 @@ 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" + 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() +} + +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, + 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") @@ -81,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 @@ -89,14 +238,14 @@ func Test_runCreateCmd(t *testing.T) { wantOut string }{ { - name: "no TTY", - cli: "", + name: "no TTY prints the bare key", + cli: "--acl search", isTTY: false, - wantOut: "", + wantOut: "foo\n", }, { name: "TTY", - cli: "", + cli: "--acl search", isTTY: true, wantOut: "✓ API key created: foo\n", }, @@ -104,14 +253,19 @@ 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"), 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) @@ -121,3 +275,498 @@ 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(), "isn't an admin key") + assert.Contains(t, err.Error(), "ALGOLIA_API_KEY") + assert.NotContains(t, err.Error(), "algolia auth login") + }) + + t.Run("non-403 errors pass through", func(t *testing.T) { + withoutSession(t) + + io, _, _, _ := iostreams.Test() + opts := &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/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/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..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(