Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 68 additions & 9 deletions api/dashboard/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -544,34 +544,34 @@ func (c *Client) CreateAPIKey(
accessToken, appID string,
acl []string,
description string,
) (string, error) {
) (CreatedAPIKey, error) {
payload := CreateAPIKeyRequest{ACL: acl, Description: description}
body, err := json.Marshal(payload)
if err != nil {
return "", err
return CreatedAPIKey{}, 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 "", err
return CreatedAPIKey{}, err
}
c.setAPIHeaders(req, accessToken)
req.Header.Set("Content-Type", "application/json")

resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("create API key request failed: %w", err)
return CreatedAPIKey{}, fmt.Errorf("create API key request failed: %w", err)
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read API key response: %w", err)
return CreatedAPIKey{}, fmt.Errorf("failed to read API key response: %w", err)
}

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf(
return CreatedAPIKey{}, fmt.Errorf(
"create API key failed with status %d: %s",
resp.StatusCode,
string(respBody),
Expand All @@ -580,7 +580,7 @@ func (c *Client) CreateAPIKey(

var keyResp CreateAPIKeyResponse
if err := json.Unmarshal(respBody, &keyResp); err != nil {
return "", fmt.Errorf(
return CreatedAPIKey{}, fmt.Errorf(
"failed to parse API key response: %w (body: %s)",
err,
string(respBody),
Expand All @@ -589,13 +589,72 @@ func (c *Client) CreateAPIKey(

key := keyResp.Data.Attributes.Value
if key == "" {
return "", fmt.Errorf(
return CreatedAPIKey{}, fmt.Errorf(
"API key creation succeeded but no key was returned in the response: %s",
string(respBody),
)
}

return key, nil
return CreatedAPIKey{Value: key, UUID: keyResp.Data.ID}, nil
}

// RotateAPIKey regenerates the secret value of the API key identified by keyUUID
// for the given application, via the Public API. The key keeps its UUID; only
// its value changes. Returns the new value and the (unchanged) UUID.
func (c *Client) RotateAPIKey(accessToken, appID, keyUUID string) (CreatedAPIKey, error) {
endpoint := fmt.Sprintf(
"%s/1/applications/%s/api-keys/%s/rotate",
c.APIURL,
url.PathEscape(appID),
url.PathEscape(keyUUID),
)
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil {
return CreatedAPIKey{}, err
}
c.setAPIHeaders(req, accessToken)

resp, err := c.client.Do(req)
if err != nil {
return CreatedAPIKey{}, fmt.Errorf("rotate API key request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusUnauthorized {
return CreatedAPIKey{}, ErrSessionExpired
}

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return CreatedAPIKey{}, fmt.Errorf("failed to read API key response: %w", err)
}

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return CreatedAPIKey{}, fmt.Errorf(
"rotate API key failed with status %d: %s",
resp.StatusCode,
string(respBody),
)
}

var keyResp CreateAPIKeyResponse
if err := json.Unmarshal(respBody, &keyResp); err != nil {
return CreatedAPIKey{}, 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(
"API key rotation succeeded but no key was returned in the response: %s",
string(respBody),
)
}

return CreatedAPIKey{Value: key, UUID: keyResp.Data.ID}, nil
}

// GetCrawlerUser gets the crawler API user data for the current authenticated user
Expand Down
106 changes: 106 additions & 0 deletions api/dashboard/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,112 @@ func TestCreateApplication_Success(t *testing.T) {
assert.Equal(t, "My App", app.Name)
}

func TestCreateAPIKey_ReturnsValueAndUUID(t *testing.T) {
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, "Bearer test-token", r.Header.Get("Authorization"))

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"},
},
}))
})

ts, client := newTestClient(mux)
defer ts.Close()

created, err := client.CreateAPIKey("test-token", "APP1", WriteACL, "Algolia CLI")
require.NoError(t, err)
assert.Equal(t, "secret-key", created.Value)
assert.Equal(t, "key-uuid-123", created.UUID)
}

func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{
Data: APIKeyResource{
ID: "key-uuid-123",
Attributes: APIKeyAttributes{Value: ""},
},
}))
})

ts, client := newTestClient(mux)
defer ts.Close()

_, err := client.CreateAPIKey("test-token", "APP1", WriteACL, "Algolia CLI")
require.Error(t, err)
assert.Contains(t, err.Error(), "no key was returned")
}

func TestRotateAPIKey_ReturnsNewValue(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc(
"/1/applications/APP1/api-keys/key-uuid-123/rotate",
func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{
Data: APIKeyResource{
ID: "key-uuid-123",
Type: "api_key",
Attributes: APIKeyAttributes{Value: "rotated-key"},
},
}))
},
)

ts, client := newTestClient(mux)
defer ts.Close()

created, err := client.RotateAPIKey("test-token", "APP1", "key-uuid-123")
require.NoError(t, err)
assert.Equal(t, "rotated-key", created.Value)
assert.Equal(t, "key-uuid-123", created.UUID)
}

func TestRotateAPIKey_Unauthorized(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc(
"/1/applications/APP1/api-keys/key-uuid-123/rotate",
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
},
)

ts, client := newTestClient(mux)
defer ts.Close()

_, err := client.RotateAPIKey("test-token", "APP1", "key-uuid-123")
assert.ErrorIs(t, err, ErrSessionExpired)
}

func TestRotateAPIKey_ErrorStatus(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc(
"/1/applications/APP1/api-keys/key-uuid-123/rotate",
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("not found"))
},
)

ts, client := newTestClient(mux)
defer ts.Close()

_, err := client.RotateAPIKey("test-token", "APP1", "key-uuid-123")
require.Error(t, err)
assert.Contains(t, err.Error(), "404")
}

func TestUpdateApplication_Success(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1", func(w http.ResponseWriter, r *http.Request) {
Expand Down
16 changes: 12 additions & 4 deletions api/dashboard/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ type ApplicationPlan struct {

// Application is a flattened view of an Algolia application for CLI consumption.
type Application struct {
ID string `json:"id"`
Name string `json:"name"`
APIKey string `json:"api_key,omitempty"`
PlanLabel string `json:"plan_label,omitempty"` // current plan label, e.g. "Grow Plus"
ID string `json:"id"`
Name string `json:"name"`
APIKey string `json:"api_key,omitempty"`
APIKeyUUID string `json:"api_key_uuid,omitempty"`
PlanLabel string `json:"plan_label,omitempty"` // current plan label, e.g. "Grow Plus"
}

// PaginationMeta contains page-based pagination metadata.
Expand Down Expand Up @@ -145,6 +146,13 @@ type CreateAPIKeyResponse struct {
Data APIKeyResource `json:"data"`
}

// CreatedAPIKey is the result of creating an API key: its secret value and its
// UUID, used to reference the key when persisting or managing it later.
type CreatedAPIKey struct {
Value string
UUID string
}

// DashboardCrawlerUserData contains the user information from the crawler API
type DashboardCrawlerUserData struct {
ID string `json:"id"`
Expand Down
2 changes: 1 addition & 1 deletion pkg/auth/auth_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func DisableAuthCheck(cmd *cobra.Command) {
cmd.Annotations["skipAuthCheck"] = "true"
}

func CheckAuth(cfg config.Config) error {
func CheckAuth(cfg *config.Config) error {
if cfg.Profile().Name == "" {
cfg.Profile().LoadDefault()
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/apikeys/apikeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/algolia/cli/pkg/cmd/apikeys/delete"
"github.com/algolia/cli/pkg/cmd/apikeys/get"
"github.com/algolia/cli/pkg/cmd/apikeys/list"
"github.com/algolia/cli/pkg/cmd/apikeys/rotate"
"github.com/algolia/cli/pkg/cmdutil"
)

Expand All @@ -22,6 +23,7 @@ func NewAPIKeysCmd(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(create.NewCreateCmd(f, nil))
cmd.AddCommand(delete.NewDeleteCmd(f, nil))
cmd.AddCommand(get.NewGetCmd(f, nil))
cmd.AddCommand(rotate.NewRotateCmd(f, nil))

return cmd
}
Loading
Loading