Skip to content
Closed
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
94 changes: 93 additions & 1 deletion api/dashboard/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,98 @@ func (c *Client) CreateAPIKey(
return CreatedAPIKey{Value: key.Value, UUID: key.UUID}, nil
}

func (c *Client) ListAPIKeys(accessToken, appID string) ([]APIKey, error) {
allKeys := []APIKey{}

for page := 1; ; page++ {
keysResp, err := c.listAPIKeysPage(accessToken, appID, page)
if err != nil {
return nil, err
}

if len(keysResp.Data) == 0 {
return allKeys, nil
}

if keysResp.Meta.TotalPages <= 0 {
return nil, fmt.Errorf(
"list API keys returned %d keys on page %d without pagination metadata",
len(keysResp.Data),
page,
)
}

if keysResp.Meta.CurrentPage != 0 && keysResp.Meta.CurrentPage != page {
return allKeys, nil
}

for i := range keysResp.Data {
allKeys = append(allKeys, keysResp.Data[i].toAPIKey())
}

if page >= keysResp.Meta.TotalPages {
return allKeys, nil
}
}
}

func notFoundError(body io.Reader) error {
raw, err := io.ReadAll(body)
if err != nil {
return ErrEndpointNotAvailable
}

var envelope struct {
Errors []json.RawMessage `json:"errors"`
}
if err := json.Unmarshal(bytes.TrimSpace(raw), &envelope); err != nil ||
len(envelope.Errors) == 0 {
return ErrEndpointNotAvailable
}

return ErrApplicationNotFound
}

func (c *Client) listAPIKeysPage(
accessToken, appID string,
page int,
) (*APIKeysResponse, error) {
endpoint := fmt.Sprintf(
"%s/1/applications/%s/api-keys?page=%d",
c.APIURL,
url.PathEscape(appID),
page,
)
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
c.setAPIHeaders(req, accessToken)

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

if resp.StatusCode == http.StatusUnauthorized {
return nil, ErrSessionExpired
}
if resp.StatusCode == http.StatusNotFound {
return nil, notFoundError(resp.Body)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list API keys failed with status: %d", resp.StatusCode)
}

var keysResp APIKeysResponse
if err := json.NewDecoder(resp.Body).Decode(&keysResp); err != nil {
return nil, fmt.Errorf("failed to parse API keys response: %w", err)
}

return &keysResp, nil
}

func (c *Client) CreateAPIKeyWithParams(
accessToken, appID string,
params CreateAPIKeyRequest,
Expand Down Expand Up @@ -584,7 +676,7 @@ func (c *Client) CreateAPIKeyWithParams(
return APIKey{}, ErrSessionExpired
}
if resp.StatusCode == http.StatusNotFound {
return APIKey{}, ErrApplicationNotFound
return APIKey{}, notFoundError(resp.Body)
}

respBody, err := io.ReadAll(resp.Body)
Expand Down
255 changes: 255 additions & 0 deletions api/dashboard/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,245 @@ func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) {
assert.Contains(t, err.Error(), "no key was returned")
}

func TestListAPIKeys_FollowsPagination(t *testing.T) {
var requestedPages []string

mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

page := r.URL.Query().Get("page")
requestedPages = append(requestedPages, page)
require.LessOrEqual(t, len(requestedPages), 3, "the pagination loop is unbounded")

resource := APIKeyResource{
ID: "uuid-" + page,
Type: "api_key",
Attributes: APIKeyAttributes{
Value: "key-" + page,
ACL: []string{"search"},
},
}

current := 1
if page == "2" {
current = 2
}

require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{
Data: []APIKeyResource{resource},
Meta: PaginationMeta{CurrentPage: current, TotalPages: 2, TotalCount: 2, PerPage: 1},
}))
})

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

keys, err := client.ListAPIKeys("test-token", "APP1")
require.NoError(t, err)

assert.Equal(t, []string{"1", "2"}, requestedPages)
require.Len(t, keys, 2)
assert.Equal(t, "uuid-1", keys[0].UUID)
assert.Equal(t, "key-1", keys[0].Value)
assert.Equal(t, "uuid-2", keys[1].UUID)
assert.Equal(t, []string{"search"}, keys[1].ACL)
}

func TestListAPIKeys_StopsWhenTheServerRepeatsThePage(t *testing.T) {
var requests int

mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) {
requests++
require.LessOrEqual(t, requests, 3, "the pagination loop is unbounded")

require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{
Data: []APIKeyResource{{
ID: "uuid-1",
Type: "api_key",
Attributes: APIKeyAttributes{Value: "key-1"},
}},
Meta: PaginationMeta{CurrentPage: 1, TotalPages: 3, TotalCount: 3, PerPage: 1},
}))
})

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

keys, err := client.ListAPIKeys("test-token", "APP1")
require.NoError(t, err)
assert.Equal(t, 2, requests)
require.Len(t, keys, 1)
assert.Equal(t, "uuid-1", keys[0].UUID)
}

func TestListAPIKeys_ErrorsWhenAPageHasNoPaginationMetadata(t *testing.T) {
var requests int

mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) {
requests++
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"data": []APIKeyResource{{
ID: "uuid-1",
Type: "api_key",
Attributes: APIKeyAttributes{Value: "key-1"},
}},
}))
})

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

keys, err := client.ListAPIKeys("test-token", "APP1")
require.Error(t, err)
assert.Contains(t, err.Error(), "without pagination metadata")
assert.Nil(t, keys)
assert.Equal(t, 1, requests)
}

func TestListAPIKeys_StopsOnAnEmptyPage(t *testing.T) {
var requests int

mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) {
requests++
require.LessOrEqual(t, requests, 2, "an empty page must stop the pagination loop")

data := []APIKeyResource{{
ID: "uuid-1",
Type: "api_key",
Attributes: APIKeyAttributes{Value: "key-1"},
}}
if r.URL.Query().Get("page") != "1" {
data = nil
}

require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{
Data: data,
Meta: PaginationMeta{CurrentPage: 1, TotalPages: 10, TotalCount: 1, PerPage: 1},
}))
})

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

keys, err := client.ListAPIKeys("test-token", "APP1")
require.NoError(t, err)
assert.Equal(t, 2, requests)
assert.Len(t, keys, 1)
}

func TestListAPIKeys_NoKeys(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) {
require.NoError(t, json.NewEncoder(w).Encode(APIKeysResponse{
Data: []APIKeyResource{},
Meta: PaginationMeta{CurrentPage: 1, TotalPages: 0, TotalCount: 0, PerPage: 15},
}))
})

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

keys, err := client.ListAPIKeys("test-token", "APP1")
require.NoError(t, err)
assert.Empty(t, keys)

marshalled, err := json.Marshal(keys)
require.NoError(t, err)
assert.Equal(t, "[]", string(marshalled))
}

func TestListAPIKeys_Errors(t *testing.T) {
tests := []struct {
name string
status int
body string
wantErr error
}{
{
name: "unauthorized",
status: http.StatusUnauthorized,
wantErr: ErrSessionExpired,
},
{
name: "unknown application",
status: http.StatusNotFound,
body: `{"errors":[{"status":"404","title":"Not Found"}]}`,
wantErr: ErrApplicationNotFound,
},
{
name: "endpoint not routed",
status: http.StatusNotFound,
body: "<!DOCTYPE html><html><body>The page you were looking for doesn't exist.</body></html>",
wantErr: ErrEndpointNotAvailable,
},
{
name: "empty body",
status: http.StatusNotFound,
wantErr: ErrEndpointNotAvailable,
},
{
name: "empty JSON object",
status: http.StatusNotFound,
body: `{}`,
wantErr: ErrEndpointNotAvailable,
},
{
name: "JSON null",
status: http.StatusNotFound,
body: `null`,
wantErr: ErrEndpointNotAvailable,
},
{
name: "JSON number",
status: http.StatusNotFound,
body: `123`,
wantErr: ErrEndpointNotAvailable,
},
{
name: "JSON string",
status: http.StatusNotFound,
body: `"Not Found"`,
wantErr: ErrEndpointNotAvailable,
},
{
name: "Rails unrouted path",
status: http.StatusNotFound,
body: `{"status":404,"error":"Not Found"}`,
wantErr: ErrEndpointNotAvailable,
},
{
name: "empty JSON:API errors array",
status: http.StatusNotFound,
body: `{"errors":[]}`,
wantErr: ErrEndpointNotAvailable,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc(
"/1/applications/APP1/api-keys",
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
},
)

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

_, err := client.ListAPIKeys("test-token", "APP1")
require.ErrorIs(t, err, tt.wantErr)
})
}
}

func TestCreateAPIKeyWithParams_SendsAllParamsAndReturnsTheKey(t *testing.T) {
var got CreateAPIKeyRequest

Expand Down Expand Up @@ -420,6 +659,22 @@ func TestCreateAPIKeyWithParams_ApplicationNotFound(t *testing.T) {
require.ErrorIs(t, err, ErrApplicationNotFound)
}

func TestCreateAPIKeyWithParams_EndpointNotRouted(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("<!DOCTYPE html><html><body>Not found</body></html>"))
})

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

_, err := client.CreateAPIKeyWithParams("test-token", "APP1", CreateAPIKeyRequest{
ACL: []string{"search"},
})
require.ErrorIs(t, err, ErrEndpointNotAvailable)
}

func TestRotateAPIKey_ReturnsNewValue(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc(
Expand Down
Loading
Loading