Skip to content
Merged
43 changes: 32 additions & 11 deletions api/dashboard/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down
82 changes: 82 additions & 0 deletions api/dashboard/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
42 changes: 41 additions & 1 deletion api/dashboard/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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{
Expand Down
26 changes: 26 additions & 0 deletions pkg/auth/auth_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions pkg/auth/auth_check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
19 changes: 17 additions & 2 deletions pkg/auth/authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading
Loading