From 621fe13278d7d553f864b0369f0af2ed6beae1e9 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Thu, 9 Jan 2025 16:35:34 -0800 Subject: [PATCH 1/2] support Spaces Keys API --- godo.go | 2 + spaces_keys.go | 164 ++++++++++++++++++++++++++++++++++++++++++++ spaces_keys_test.go | 130 +++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 spaces_keys.go create mode 100644 spaces_keys_test.go diff --git a/godo.go b/godo.go index 2469c14..ed46989 100644 --- a/godo.go +++ b/godo.go @@ -88,6 +88,7 @@ type Client struct { ReservedIPV6Actions ReservedIPV6ActionsService Sizes SizesService Snapshots SnapshotsService + SpacesKeys SpacesKeysService Storage StorageService StorageActions StorageActionsService Tags TagsService @@ -302,6 +303,7 @@ func NewClient(httpClient *http.Client) *Client { c.ReservedIPV6Actions = &ReservedIPV6ActionsServiceOp{client: c} c.Sizes = &SizesServiceOp{client: c} c.Snapshots = &SnapshotsServiceOp{client: c} + c.SpacesKeys = &SpacesKeysServiceOp{client: c} c.Storage = &StorageServiceOp{client: c} c.StorageActions = &StorageActionsServiceOp{client: c} c.Tags = &TagsServiceOp{client: c} diff --git a/spaces_keys.go b/spaces_keys.go new file mode 100644 index 0000000..f37b311 --- /dev/null +++ b/spaces_keys.go @@ -0,0 +1,164 @@ +package godo + +import ( + "context" + "fmt" + "net/http" +) + +const spacesKeysBasePath = "v2/spaces/keys" + +// SpacesKeysService is an interface for managing Spaces keys with the DigitalOcean API. +type SpacesKeysService interface { + ListSpacesKeys(context.Context, *ListOptions) ([]*SpacesKey, *Response, error) + UpdateSpacesKey(context.Context, string, *SpacesKeyUpdateRequest) (*SpacesKey, *Response, error) + CreateSpacesKey(context.Context, *SpacesKeyCreateRequest) (*SpacesKey, *Response, error) + DeleteSpacesKey(context.Context, string) (*Response, error) +} + +// SpacesKeysServiceOp handles communication with the Spaces key related methods of the +// DigitalOcean API. +type SpacesKeysServiceOp struct { + client *Client +} + +var _ SpacesKeysService = &SpacesKeysServiceOp{} + +// Permission represents a permission for a Spaces grant +type Permission string + +const ( + // PermissionRead grants read-only access to the Spaces bucket + PermissionRead Permission = "read" + // PermissionReadWrite grants read and write access to the Spaces bucket + PermissionReadWrite Permission = "readwrite" + // PermissionFullAccess grants full access to the Spaces bucket + PermissionFullAccess Permission = "fullaccess" +) + +// Grant represents a Grant for a Spaces key +type Grant struct { + Bucket string `json:"bucket"` + Permission Permission `json:"permission"` +} + +// SpacesKey represents a DigitalOcean Spaces key +type SpacesKey struct { + Name string `json:"name"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + CreatedAt string `json:"created_at"` +} + +// SpacesKeyRoot represents a response from the DigitalOcean API +type spacesKeyRoot struct { + Key *SpacesKey `json:"key"` +} + +// SpacesKeyCreateRequest represents a request to create a Spaces key. +type SpacesKeyCreateRequest struct { + Name string `json:"name"` + Grants []*Grant `json:"grants"` +} + +// SpacesKeyUpdateRequest represents a request to update a Spaces key. +type SpacesKeyUpdateRequest struct { + Name string `json:"name"` + Grants []*Grant `json:"grants"` +} + +// spacesListKeysRoot represents a response from the DigitalOcean API +type spacesListKeysRoot struct { + Keys []*SpacesKey `json:"keys,omitempty"` + Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta"` +} + +// CreateSpacesKey implements SpacesKeysService. +func (s *SpacesKeysServiceOp) CreateSpacesKey(ctx context.Context, createRequest *SpacesKeyCreateRequest) (*SpacesKey, *Response, error) { + if createRequest == nil { + return nil, nil, NewArgError("createRequest", "cannot be nil") + } + + req, err := s.client.NewRequest(ctx, http.MethodPost, spacesKeysBasePath, createRequest) + if err != nil { + return nil, nil, err + } + + root := new(spacesKeyRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Key, resp, nil +} + +// DeleteSpacesKey implements SpacesKeysService. +func (s *SpacesKeysServiceOp) DeleteSpacesKey(ctx context.Context, accessKey string) (*Response, error) { + if accessKey == "" { + return nil, NewArgError("accessKey", "cannot be empty") + } + + path := fmt.Sprintf("%s/%s", spacesKeysBasePath, accessKey) + req, err := s.client.NewRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +// UpdateSpacesKey +func (s *SpacesKeysServiceOp) UpdateSpacesKey(ctx context.Context, accessKey string, updateRequest *SpacesKeyUpdateRequest) (*SpacesKey, *Response, error) { + if accessKey == "" { + return nil, nil, NewArgError("accessKey", "cannot be empty") + } + if updateRequest == nil { + return nil, nil, NewArgError("updateRequest", "cannot be nil") + } + + path := fmt.Sprintf("%s/%s", spacesKeysBasePath, accessKey) + req, err := s.client.NewRequest(ctx, http.MethodPut, path, updateRequest) + if err != nil { + return nil, nil, err + } + root := new(spacesKeyRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Key, resp, nil +} + +// ListSpacesKeys returns a list of Spaces keys. +func (s *SpacesKeysServiceOp) ListSpacesKeys(ctx context.Context, opts *ListOptions) ([]*SpacesKey, *Response, error) { + path, err := addOptions(spacesKeysBasePath, opts) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(spacesListKeysRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + if root.Links != nil { + resp.Links = root.Links + } + if root.Meta != nil { + resp.Meta = root.Meta + } + + return root.Keys, resp, nil +} diff --git a/spaces_keys_test.go b/spaces_keys_test.go new file mode 100644 index 0000000..b1cea22 --- /dev/null +++ b/spaces_keys_test.go @@ -0,0 +1,130 @@ +package godo + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSpacesKeyCreate(t *testing.T) { + setup() + defer teardown() + + createRequest := &SpacesKeyCreateRequest{ + Name: "test-key", + Grants: []*Grant{ + { + Bucket: "test-bucket", + Permission: PermissionRead, + }, + }, + } + + mux.HandleFunc("/v2/spaces/keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"key":{"name":"test-key","access_key":"test-access-key","secret_key":"test-secret-key","created_at":"2023-10-01T00:00:00Z"}}`) + }) + + key, resp, err := client.SpacesKeys.CreateSpacesKey(context.Background(), createRequest) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, "test-key", key.Name) + assert.Equal(t, "test-access-key", key.AccessKey) + assert.Equal(t, "test-secret-key", key.SecretKey) +} + +func TestSpacesKeyDelete(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/spaces/keys/test-access-key", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := client.SpacesKeys.DeleteSpacesKey(context.Background(), "test-access-key") + assert.NoError(t, err) + assert.NotNil(t, resp) +} + +func TestSpacesKeyUpdate(t *testing.T) { + setup() + defer teardown() + + updateRequest := &SpacesKeyUpdateRequest{ + Name: "updated-key", + Grants: []*Grant{ + { + Bucket: "updated-bucket", + Permission: PermissionReadWrite, + }, + }, + } + + mux.HandleFunc("/v2/spaces/keys/test-access-key", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"key":{"name":"updated-key","access_key":"test-access-key","created_at":"2023-10-01T00:00:00Z"}}`) + }) + + key, resp, err := client.SpacesKeys.UpdateSpacesKey(context.Background(), "test-access-key", updateRequest) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, "updated-key", key.Name) + assert.Equal(t, "test-access-key", key.AccessKey) +} + +func TestSpacesKeyList(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/spaces/keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"keys":[{"name":"test-key","access_key":"test-access-key","created_at":"2023-10-01T00:00:00Z"}]}`) + }) + + keys, resp, err := client.SpacesKeys.ListSpacesKeys(context.Background(), nil) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, keys, 1) + assert.Equal(t, "test-key", keys[0].Name) + assert.Equal(t, "test-access-key", keys[0].AccessKey) +} + +func TestSpacesKeyList_Pagination(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/spaces/keys", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + page := r.URL.Query().Get("page") + if page == "2" { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"keys":[{"name":"test-key-2","access_key":"test-access-key-2","created_at":"2023-10-02T00:00:00Z"}]}`) + } else { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"keys":[{"name":"test-key-1","access_key":"test-access-key-1","created_at":"2023-10-01T00:00:00Z"}]}`) + } + }) + + // Test first page + keys, resp, err := client.SpacesKeys.ListSpacesKeys(context.Background(), &ListOptions{Page: 1}) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, keys, 1) + assert.Equal(t, "test-key-1", keys[0].Name) + assert.Equal(t, "test-access-key-1", keys[0].AccessKey) + + // Test second page + keys, resp, err = client.SpacesKeys.ListSpacesKeys(context.Background(), &ListOptions{Page: 2}) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Len(t, keys, 1) + assert.Equal(t, "test-key-2", keys[0].Name) + assert.Equal(t, "test-access-key-2", keys[0].AccessKey) +} From 079c1c004729d05dde6496b7e843588e0b4c213b Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Fri, 10 Jan 2025 09:26:10 -0800 Subject: [PATCH 2/2] Update func names and permission type --- spaces_keys.go | 53 +++++++++++++++++++++++---------------------- spaces_keys_test.go | 41 ++++++++++++++++++++++++----------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/spaces_keys.go b/spaces_keys.go index f37b311..371d8a1 100644 --- a/spaces_keys.go +++ b/spaces_keys.go @@ -10,10 +10,10 @@ const spacesKeysBasePath = "v2/spaces/keys" // SpacesKeysService is an interface for managing Spaces keys with the DigitalOcean API. type SpacesKeysService interface { - ListSpacesKeys(context.Context, *ListOptions) ([]*SpacesKey, *Response, error) - UpdateSpacesKey(context.Context, string, *SpacesKeyUpdateRequest) (*SpacesKey, *Response, error) - CreateSpacesKey(context.Context, *SpacesKeyCreateRequest) (*SpacesKey, *Response, error) - DeleteSpacesKey(context.Context, string) (*Response, error) + List(context.Context, *ListOptions) ([]*SpacesKey, *Response, error) + Update(context.Context, string, *SpacesKeyUpdateRequest) (*SpacesKey, *Response, error) + Create(context.Context, *SpacesKeyCreateRequest) (*SpacesKey, *Response, error) + Delete(context.Context, string) (*Response, error) } // SpacesKeysServiceOp handles communication with the Spaces key related methods of the @@ -24,30 +24,31 @@ type SpacesKeysServiceOp struct { var _ SpacesKeysService = &SpacesKeysServiceOp{} -// Permission represents a permission for a Spaces grant -type Permission string +// SpacesKeyPermission represents a permission for a Spaces grant +type SpacesKeyPermission string const ( - // PermissionRead grants read-only access to the Spaces bucket - PermissionRead Permission = "read" - // PermissionReadWrite grants read and write access to the Spaces bucket - PermissionReadWrite Permission = "readwrite" - // PermissionFullAccess grants full access to the Spaces bucket - PermissionFullAccess Permission = "fullaccess" + // SpacesKeyRead grants read-only access to the Spaces bucket + SpacesKeyRead SpacesKeyPermission = "read" + // SpacesKeyReadWrite grants read and write access to the Spaces bucket + SpacesKeyReadWrite SpacesKeyPermission = "readwrite" + // SpacesKeyFullAccess grants full access to the Spaces bucket + SpacesKeyFullAccess SpacesKeyPermission = "fullaccess" ) // Grant represents a Grant for a Spaces key type Grant struct { - Bucket string `json:"bucket"` - Permission Permission `json:"permission"` + Bucket string `json:"bucket"` + Permission SpacesKeyPermission `json:"permission"` } // SpacesKey represents a DigitalOcean Spaces key type SpacesKey struct { - Name string `json:"name"` - AccessKey string `json:"access_key"` - SecretKey string `json:"secret_key"` - CreatedAt string `json:"created_at"` + Name string `json:"name"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Grants []*Grant `json:"grants"` + CreatedAt string `json:"created_at"` } // SpacesKeyRoot represents a response from the DigitalOcean API @@ -74,8 +75,8 @@ type spacesListKeysRoot struct { Meta *Meta `json:"meta"` } -// CreateSpacesKey implements SpacesKeysService. -func (s *SpacesKeysServiceOp) CreateSpacesKey(ctx context.Context, createRequest *SpacesKeyCreateRequest) (*SpacesKey, *Response, error) { +// Create creates a new Spaces key. +func (s *SpacesKeysServiceOp) Create(ctx context.Context, createRequest *SpacesKeyCreateRequest) (*SpacesKey, *Response, error) { if createRequest == nil { return nil, nil, NewArgError("createRequest", "cannot be nil") } @@ -94,8 +95,8 @@ func (s *SpacesKeysServiceOp) CreateSpacesKey(ctx context.Context, createRequest return root.Key, resp, nil } -// DeleteSpacesKey implements SpacesKeysService. -func (s *SpacesKeysServiceOp) DeleteSpacesKey(ctx context.Context, accessKey string) (*Response, error) { +// Delete deletes a Spaces key. +func (s *SpacesKeysServiceOp) Delete(ctx context.Context, accessKey string) (*Response, error) { if accessKey == "" { return nil, NewArgError("accessKey", "cannot be empty") } @@ -113,8 +114,8 @@ func (s *SpacesKeysServiceOp) DeleteSpacesKey(ctx context.Context, accessKey str return resp, nil } -// UpdateSpacesKey -func (s *SpacesKeysServiceOp) UpdateSpacesKey(ctx context.Context, accessKey string, updateRequest *SpacesKeyUpdateRequest) (*SpacesKey, *Response, error) { +// Update updates a Spaces key. +func (s *SpacesKeysServiceOp) Update(ctx context.Context, accessKey string, updateRequest *SpacesKeyUpdateRequest) (*SpacesKey, *Response, error) { if accessKey == "" { return nil, nil, NewArgError("accessKey", "cannot be empty") } @@ -136,8 +137,8 @@ func (s *SpacesKeysServiceOp) UpdateSpacesKey(ctx context.Context, accessKey str return root.Key, resp, nil } -// ListSpacesKeys returns a list of Spaces keys. -func (s *SpacesKeysServiceOp) ListSpacesKeys(ctx context.Context, opts *ListOptions) ([]*SpacesKey, *Response, error) { +// List returns a list of Spaces keys. +func (s *SpacesKeysServiceOp) List(ctx context.Context, opts *ListOptions) ([]*SpacesKey, *Response, error) { path, err := addOptions(spacesKeysBasePath, opts) if err != nil { return nil, nil, err diff --git a/spaces_keys_test.go b/spaces_keys_test.go index b1cea22..3b81254 100644 --- a/spaces_keys_test.go +++ b/spaces_keys_test.go @@ -18,7 +18,7 @@ func TestSpacesKeyCreate(t *testing.T) { Grants: []*Grant{ { Bucket: "test-bucket", - Permission: PermissionRead, + Permission: SpacesKeyRead, }, }, } @@ -26,15 +26,18 @@ func TestSpacesKeyCreate(t *testing.T) { mux.HandleFunc("/v2/spaces/keys", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) w.WriteHeader(http.StatusCreated) - fmt.Fprint(w, `{"key":{"name":"test-key","access_key":"test-access-key","secret_key":"test-secret-key","created_at":"2023-10-01T00:00:00Z"}}`) + fmt.Fprint(w, `{"key":{"name":"test-key","access_key":"test-access-key","secret_key":"test-secret-key","created_at":"2023-10-01T00:00:00Z","grants":[{"bucket":"test-bucket","permission":"read"}]}}`) }) - key, resp, err := client.SpacesKeys.CreateSpacesKey(context.Background(), createRequest) + key, resp, err := client.SpacesKeys.Create(context.Background(), createRequest) assert.NoError(t, err) assert.NotNil(t, resp) assert.Equal(t, "test-key", key.Name) assert.Equal(t, "test-access-key", key.AccessKey) assert.Equal(t, "test-secret-key", key.SecretKey) + assert.Len(t, key.Grants, 1) + assert.Equal(t, "test-bucket", key.Grants[0].Bucket) + assert.Equal(t, SpacesKeyRead, key.Grants[0].Permission) } func TestSpacesKeyDelete(t *testing.T) { @@ -46,7 +49,7 @@ func TestSpacesKeyDelete(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - resp, err := client.SpacesKeys.DeleteSpacesKey(context.Background(), "test-access-key") + resp, err := client.SpacesKeys.Delete(context.Background(), "test-access-key") assert.NoError(t, err) assert.NotNil(t, resp) } @@ -60,7 +63,7 @@ func TestSpacesKeyUpdate(t *testing.T) { Grants: []*Grant{ { Bucket: "updated-bucket", - Permission: PermissionReadWrite, + Permission: SpacesKeyReadWrite, }, }, } @@ -68,14 +71,17 @@ func TestSpacesKeyUpdate(t *testing.T) { mux.HandleFunc("/v2/spaces/keys/test-access-key", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPut, r.Method) w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"key":{"name":"updated-key","access_key":"test-access-key","created_at":"2023-10-01T00:00:00Z"}}`) + fmt.Fprint(w, `{"key":{"name":"updated-key","access_key":"test-access-key","created_at":"2023-10-01T00:00:00Z","grants":[{"bucket":"updated-bucket","permission":"readwrite"}]}}`) }) - key, resp, err := client.SpacesKeys.UpdateSpacesKey(context.Background(), "test-access-key", updateRequest) + key, resp, err := client.SpacesKeys.Update(context.Background(), "test-access-key", updateRequest) assert.NoError(t, err) assert.NotNil(t, resp) assert.Equal(t, "updated-key", key.Name) assert.Equal(t, "test-access-key", key.AccessKey) + assert.Len(t, key.Grants, 1) + assert.Equal(t, "updated-bucket", key.Grants[0].Bucket) + assert.Equal(t, SpacesKeyReadWrite, key.Grants[0].Permission) } func TestSpacesKeyList(t *testing.T) { @@ -85,15 +91,18 @@ func TestSpacesKeyList(t *testing.T) { mux.HandleFunc("/v2/spaces/keys", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodGet, r.Method) w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"keys":[{"name":"test-key","access_key":"test-access-key","created_at":"2023-10-01T00:00:00Z"}]}`) + fmt.Fprint(w, `{"keys":[{"name":"test-key","access_key":"test-access-key","created_at":"2023-10-01T00:00:00Z","grants":[{"bucket":"test-bucket","permission":"read"}]}]}`) }) - keys, resp, err := client.SpacesKeys.ListSpacesKeys(context.Background(), nil) + keys, resp, err := client.SpacesKeys.List(context.Background(), nil) assert.NoError(t, err) assert.NotNil(t, resp) assert.Len(t, keys, 1) assert.Equal(t, "test-key", keys[0].Name) assert.Equal(t, "test-access-key", keys[0].AccessKey) + assert.Len(t, keys[0].Grants, 1) + assert.Equal(t, "test-bucket", keys[0].Grants[0].Bucket) + assert.Equal(t, SpacesKeyRead, keys[0].Grants[0].Permission) } func TestSpacesKeyList_Pagination(t *testing.T) { @@ -105,26 +114,32 @@ func TestSpacesKeyList_Pagination(t *testing.T) { page := r.URL.Query().Get("page") if page == "2" { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"keys":[{"name":"test-key-2","access_key":"test-access-key-2","created_at":"2023-10-02T00:00:00Z"}]}`) + fmt.Fprint(w, `{"keys":[{"name":"test-key-2","access_key":"test-access-key-2","created_at":"2023-10-02T00:00:00Z","grants":[{"bucket":"test-bucket-2","permission":"readwrite"}]}]}`) } else { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"keys":[{"name":"test-key-1","access_key":"test-access-key-1","created_at":"2023-10-01T00:00:00Z"}]}`) + fmt.Fprint(w, `{"keys":[{"name":"test-key-1","access_key":"test-access-key-1","created_at":"2023-10-01T00:00:00Z","grants":[{"bucket":"test-bucket-1","permission":"read"}]}]}`) } }) // Test first page - keys, resp, err := client.SpacesKeys.ListSpacesKeys(context.Background(), &ListOptions{Page: 1}) + keys, resp, err := client.SpacesKeys.List(context.Background(), &ListOptions{Page: 1}) assert.NoError(t, err) assert.NotNil(t, resp) assert.Len(t, keys, 1) assert.Equal(t, "test-key-1", keys[0].Name) assert.Equal(t, "test-access-key-1", keys[0].AccessKey) + assert.Len(t, keys[0].Grants, 1) + assert.Equal(t, "test-bucket-1", keys[0].Grants[0].Bucket) + assert.Equal(t, SpacesKeyRead, keys[0].Grants[0].Permission) // Test second page - keys, resp, err = client.SpacesKeys.ListSpacesKeys(context.Background(), &ListOptions{Page: 2}) + keys, resp, err = client.SpacesKeys.List(context.Background(), &ListOptions{Page: 2}) assert.NoError(t, err) assert.NotNil(t, resp) assert.Len(t, keys, 1) assert.Equal(t, "test-key-2", keys[0].Name) assert.Equal(t, "test-access-key-2", keys[0].AccessKey) + assert.Len(t, keys[0].Grants, 1) + assert.Equal(t, "test-bucket-2", keys[0].Grants[0].Bucket) + assert.Equal(t, SpacesKeyReadWrite, keys[0].Grants[0].Permission) }