From 14190baac808591992841e38291b808c319b2d67 Mon Sep 17 00:00:00 2001 From: riku salkia Date: Thu, 19 Dec 2024 14:07:20 +0200 Subject: [PATCH 01/16] feat(account): add api token models --- upcloud/token.go | 26 ++++++++++++ upcloud/token_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 upcloud/token.go create mode 100644 upcloud/token_test.go diff --git a/upcloud/token.go b/upcloud/token.go new file mode 100644 index 0000000..b3e84f9 --- /dev/null +++ b/upcloud/token.go @@ -0,0 +1,26 @@ +package upcloud + +import ( + "time" +) + +type TokenType string + +const ( + TokenTypePAT = "pat" + TokenTypeWorkspace = "workspace" +) + +type Tokens []Token + +type Token struct { + APIToken string `json:"token,omitempty"` // APIToken is the API token. Returned only when creating a new token. + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Created time.Time `json:"created_at"` + Expires time.Time `json:"expires_at"` + LastUsed *time.Time `json:"last_used_at,omitempty"` + CanCreateSubTokens bool `json:"can_create_tokens"` + AllowedIPRanges []string `json:"allowed_ip_ranges"` +} diff --git a/upcloud/token_test.go b/upcloud/token_test.go new file mode 100644 index 0000000..48e4c35 --- /dev/null +++ b/upcloud/token_test.go @@ -0,0 +1,96 @@ +package upcloud + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTokenUnmarshal(t *testing.T) { + want := Tokens{ + { + ID: "deadbeef-dead-beef-dead-beefdeadbee1", + Name: "token_workspace", + Type: TokenTypeWorkspace, + Created: timeParse("2024-12-19T11:46:09.888763Z"), + Expires: timeParse("2024-12-19T12:16:09.888531Z"), + LastUsed: nil, + CanCreateSubTokens: true, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + }, + { + ID: "deadbeef-dead-beef-dead-beefdeadbee2", + Name: "token_pat", + Type: TokenTypePAT, + Created: timeParse("2024-12-19T11:57:33.40507Z"), + Expires: timeParse("2024-12-19T12:27:33.404897Z"), + LastUsed: TimePtr(timeParse("2024-12-19T12:01:13.538016Z")), + CanCreateSubTokens: true, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + }, + } + got := Tokens{} + err := json.Unmarshal([]byte(` + [ + { + "id": "deadbeef-dead-beef-dead-beefdeadbee1", + "name": "token_workspace", + "type": "workspace", + "created_at": "2024-12-19T11:46:09.888763Z", + "expires_at": "2024-12-19T12:16:09.888531Z", + "can_create_tokens": true, + "allowed_ip_ranges": [ + "0.0.0.0/0", + "::/0" + ] + }, + { + "id": "deadbeef-dead-beef-dead-beefdeadbee2", + "name": "token_pat", + "type": "pat", + "created_at": "2024-12-19T11:57:33.40507Z", + "expires_at": "2024-12-19T12:27:33.404897Z", + "last_used_at": "2024-12-19T12:01:13.538016Z", + "can_create_tokens": true, + "allowed_ip_ranges": [ + "0.0.0.0/0", + "::/0" + ] + } + ] +`), &got) + assert.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestTokenMarshal(t *testing.T) { + want := ` + { + "token": "ucat_01DEADBEEFDEADBEEFDEADBEEF", + "id": "deadbeef-dead-beef-dead-beefdeadbeef", + "name": "test_token", + "type": "workspace", + "created_at": "2024-12-19T11:46:09.888763Z", + "expires_at": "2024-12-19T12:16:09.888531Z", + "can_create_tokens": true, + "allowed_ip_ranges": [ + "0.0.0.0/0", + "::/0" + ] + } + ` + got, err := json.Marshal(&Token{ + APIToken: "ucat_01DEADBEEFDEADBEEFDEADBEEF", + ID: "deadbeef-dead-beef-dead-beefdeadbeef", + Name: "test_token", + Type: TokenTypeWorkspace, + Created: timeParse("2024-12-19T11:46:09.888763Z"), + Expires: timeParse("2024-12-19T12:16:09.888531Z"), + LastUsed: nil, + CanCreateSubTokens: true, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + }) + assert.NoError(t, err) + assert.JSONEq(t, want, string(got)) +} From 451df589a1e554f8c4ff402279d893c27d4ee163 Mon Sep 17 00:00:00 2001 From: riku salkia Date: Thu, 19 Dec 2024 14:48:17 +0200 Subject: [PATCH 02/16] feat(account): add api token request objects --- upcloud/request/token.go | 45 +++++++++++++++++++++++++++++++++++ upcloud/request/token_test.go | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 upcloud/request/token.go create mode 100644 upcloud/request/token_test.go diff --git a/upcloud/request/token.go b/upcloud/request/token.go new file mode 100644 index 0000000..b3fe408 --- /dev/null +++ b/upcloud/request/token.go @@ -0,0 +1,45 @@ +package request + +import ( + "fmt" + "time" +) + +// GetTokenDetailsRequest represents a request to get token details. Will not return the actual API token. +type GetTokenDetailsRequest struct { + ID string +} + +func (r *GetTokenDetailsRequest) RequestURL() string { + return fmt.Sprintf("/account/tokens/%s", r.ID) +} + +// GetTokensRequest represents a request to get a list of tokens. Will not return the actual API tokens. +type GetTokensRequest struct{} + +func (r *GetTokensRequest) RequestURL() string { + return "/account/tokens" +} + +// CreateTokenRequest represents a request to create a new network. +type CreateTokenRequest struct { + Name string `json:"name"` + ExpiresAt time.Time `json:"expires_at"` + CanCreateSubTokens bool `json:"can_create_tokens"` + AllowedIPRanges []string `json:"allowed_ip_ranges"` +} + +// RequestURL implements the Request interface. +func (r *CreateTokenRequest) RequestURL() string { + return "/account/tokens" +} + +// DeleteTokenRequest represents a request to delete a token. +type DeleteTokenRequest struct { + ID string +} + +// RequestURL implements the Request interface. +func (r *DeleteTokenRequest) RequestURL() string { + return fmt.Sprintf("/account/tokens/%s", r.ID) +} diff --git a/upcloud/request/token_test.go b/upcloud/request/token_test.go new file mode 100644 index 0000000..7ed4a01 --- /dev/null +++ b/upcloud/request/token_test.go @@ -0,0 +1,42 @@ +package request + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetTokenDetailsRequest(t *testing.T) { + assert.Equal(t, "/account/tokens/foo", (&GetTokenDetailsRequest{ID: "foo"}).RequestURL()) +} + +func TestGetTokensRequest(t *testing.T) { + assert.Equal(t, "/account/tokens", (&GetTokensRequest{}).RequestURL()) +} + +func TestDeleteTokenRequest(t *testing.T) { + assert.Equal(t, "/account/tokens/foo", (&DeleteTokenRequest{ID: "foo"}).RequestURL()) +} + +func TestCreateTokenRequest(t *testing.T) { + want := ` + { + "name": "my_1st_token", + "expires_at": "2025-01-01T00:00:00Z", + "can_create_tokens": true, + "allowed_ip_ranges": ["0.0.0.0/0", "::/0"] + } + ` + req := &CreateTokenRequest{ + Name: "my_1st_token", + ExpiresAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + CanCreateSubTokens: true, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + } + got, err := json.Marshal(req) + assert.NoError(t, err) + assert.JSONEq(t, want, string(got)) + assert.Equal(t, "/account/tokens", req.RequestURL()) +} From 3c675b3fa641043bc530e798f893cf6f36ce3803 Mon Sep 17 00:00:00 2001 From: riku salkia Date: Fri, 20 Dec 2024 12:52:22 +0200 Subject: [PATCH 03/16] feat(account): add token api requests --- upcloud/request/token.go | 10 +++-- upcloud/request/token_test.go | 1 + upcloud/service/token.go | 39 +++++++++++++++++ upcloud/service/token_test.go | 80 +++++++++++++++++++++++++++++++++++ upcloud/token.go | 4 +- upcloud/token_test.go | 12 +++--- 6 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 upcloud/service/token.go create mode 100644 upcloud/service/token_test.go diff --git a/upcloud/request/token.go b/upcloud/request/token.go index b3fe408..e8590ed 100644 --- a/upcloud/request/token.go +++ b/upcloud/request/token.go @@ -5,20 +5,22 @@ import ( "time" ) +const basePath = "/account/tokens" + // GetTokenDetailsRequest represents a request to get token details. Will not return the actual API token. type GetTokenDetailsRequest struct { ID string } func (r *GetTokenDetailsRequest) RequestURL() string { - return fmt.Sprintf("/account/tokens/%s", r.ID) + return fmt.Sprintf("%s/%s", basePath, r.ID) } // GetTokensRequest represents a request to get a list of tokens. Will not return the actual API tokens. type GetTokensRequest struct{} func (r *GetTokensRequest) RequestURL() string { - return "/account/tokens" + return basePath } // CreateTokenRequest represents a request to create a new network. @@ -31,7 +33,7 @@ type CreateTokenRequest struct { // RequestURL implements the Request interface. func (r *CreateTokenRequest) RequestURL() string { - return "/account/tokens" + return basePath } // DeleteTokenRequest represents a request to delete a token. @@ -41,5 +43,5 @@ type DeleteTokenRequest struct { // RequestURL implements the Request interface. func (r *DeleteTokenRequest) RequestURL() string { - return fmt.Sprintf("/account/tokens/%s", r.ID) + return fmt.Sprintf("%s/%s", basePath, r.ID) } diff --git a/upcloud/request/token_test.go b/upcloud/request/token_test.go index 7ed4a01..2c76eb3 100644 --- a/upcloud/request/token_test.go +++ b/upcloud/request/token_test.go @@ -14,6 +14,7 @@ func TestGetTokenDetailsRequest(t *testing.T) { func TestGetTokensRequest(t *testing.T) { assert.Equal(t, "/account/tokens", (&GetTokensRequest{}).RequestURL()) + assert.Equal(t, "/account/tokens", (&GetTokensRequest{}).RequestURL()) } func TestDeleteTokenRequest(t *testing.T) { diff --git a/upcloud/service/token.go b/upcloud/service/token.go new file mode 100644 index 0000000..cef9249 --- /dev/null +++ b/upcloud/service/token.go @@ -0,0 +1,39 @@ +package service + +import ( + "context" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" +) + +type Token interface { + CreateToken(context.Context, *request.CreateTokenRequest) (*upcloud.Token, error) + GetTokenDetails(ctx context.Context, r *request.GetTokenDetailsRequest) (*upcloud.Token, error) + GetTokens(context.Context) (*upcloud.Tokens, error) + DeleteToken(context.Context, *request.DeleteTokenRequest) error +} + +// CreateToken creates a new token. +func (s *Service) CreateToken(ctx context.Context, r *request.CreateTokenRequest) (*upcloud.Token, error) { + token := upcloud.Token{} + return &token, s.create(ctx, r, &token) +} + +// GetTokenDetails returns the details for the specified token. +func (s *Service) GetTokenDetails(ctx context.Context, r *request.GetTokenDetailsRequest) (*upcloud.Token, error) { + token := upcloud.Token{} + return &token, s.get(ctx, r.RequestURL(), &token) +} + +// GetTokens returns the all the available networks +func (s *Service) GetTokens(ctx context.Context) (*upcloud.Tokens, error) { + req := request.GetTokensRequest{} + tokens := upcloud.Tokens{} + return &tokens, s.get(ctx, req.RequestURL(), &tokens) +} + +// DeleteToken deletes the specified token. +func (s *Service) DeleteToken(ctx context.Context, r *request.DeleteTokenRequest) error { + return s.delete(ctx, r) +} diff --git a/upcloud/service/token_test.go b/upcloud/service/token_test.go new file mode 100644 index 0000000..734ca73 --- /dev/null +++ b/upcloud/service/token_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + "github.com/dnaeon/go-vcr/recorder" + "github.com/stretchr/testify/assert" + + "github.com/stretchr/testify/require" +) + +func TestToken(t *testing.T) { + expires := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) + tokenRequests := []request.CreateTokenRequest{ + { + Name: "my_1st_token", + ExpiresAt: expires, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + CanCreateSubTokens: true, + }, + { + Name: "my_2nd_token", + ExpiresAt: expires, + AllowedIPRanges: []string{"0.0.0.0/1", "::/0"}, + CanCreateSubTokens: false, + }, + } + + record(t, "token", func(ctx context.Context, t *testing.T, rec *recorder.Recorder, svc *Service) { + // Create some tokens + ids := make([]string, len(tokenRequests)) + + for i, req := range tokenRequests { + token, err := svc.CreateToken(ctx, &req) + require.NoError(t, err) + t.Cleanup(cleanupTokenFunc(t, svc, token.ID)) + + ids[i] = token.ID + assert.True(t, strings.HasPrefix(token.APIToken, "ucat_")) + assert.Equal(t, req.Name, token.Name) + assert.Equal(t, req.AllowedIPRanges, token.AllowedIPRanges) + assert.Equal(t, req.ExpiresAt.Format(time.RFC3339), token.ExpiresAt.Format(time.RFC3339)) + assert.Equal(t, req.CanCreateSubTokens, token.CanCreateSubTokens) + } + + // Get one token + token, err := svc.GetTokenDetails(ctx, &request.GetTokenDetailsRequest{ID: ids[0]}) + require.NoError(t, err) + + assert.Equal(t, "my_1st_token", token.Name) + assert.Equal(t, []string{"0.0.0.0/0", "::/0"}, token.AllowedIPRanges) + assert.Equal(t, expires.Format(time.RFC3339), token.ExpiresAt.Format(time.RFC3339)) + assert.Equal(t, true, token.CanCreateSubTokens) + + // List tokens + // TODO: paging. Currently lists up to 100 tokens and does not support paging. Do we want to add explicit Page + // parameter to the request, or within client request all the tokens page by page and return one possibly + // massive list? I'd go with explicit paging. + tokens, err := svc.GetTokens(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(*tokens), len(tokenRequests)) + + // Create a token and delete it immediately + deleteThis, err := svc.CreateToken(ctx, &tokenRequests[0]) + require.NoError(t, err) + require.NoError(t, svc.DeleteToken(ctx, &request.DeleteTokenRequest{ID: deleteThis.ID})) + }) +} + +func cleanupTokenFunc(t *testing.T, svc *Service, id string) func() { + return func() { + if err := svc.DeleteToken(context.Background(), &request.DeleteTokenRequest{ID: id}); err != nil { + t.Log(err) + } + } +} diff --git a/upcloud/token.go b/upcloud/token.go index b3e84f9..018f1db 100644 --- a/upcloud/token.go +++ b/upcloud/token.go @@ -18,8 +18,8 @@ type Token struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` - Created time.Time `json:"created_at"` - Expires time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` LastUsed *time.Time `json:"last_used_at,omitempty"` CanCreateSubTokens bool `json:"can_create_tokens"` AllowedIPRanges []string `json:"allowed_ip_ranges"` diff --git a/upcloud/token_test.go b/upcloud/token_test.go index 48e4c35..7713d10 100644 --- a/upcloud/token_test.go +++ b/upcloud/token_test.go @@ -13,8 +13,8 @@ func TestTokenUnmarshal(t *testing.T) { ID: "deadbeef-dead-beef-dead-beefdeadbee1", Name: "token_workspace", Type: TokenTypeWorkspace, - Created: timeParse("2024-12-19T11:46:09.888763Z"), - Expires: timeParse("2024-12-19T12:16:09.888531Z"), + CreatedAt: timeParse("2024-12-19T11:46:09.888763Z"), + ExpiresAt: timeParse("2024-12-19T12:16:09.888531Z"), LastUsed: nil, CanCreateSubTokens: true, AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, @@ -23,8 +23,8 @@ func TestTokenUnmarshal(t *testing.T) { ID: "deadbeef-dead-beef-dead-beefdeadbee2", Name: "token_pat", Type: TokenTypePAT, - Created: timeParse("2024-12-19T11:57:33.40507Z"), - Expires: timeParse("2024-12-19T12:27:33.404897Z"), + CreatedAt: timeParse("2024-12-19T11:57:33.40507Z"), + ExpiresAt: timeParse("2024-12-19T12:27:33.404897Z"), LastUsed: TimePtr(timeParse("2024-12-19T12:01:13.538016Z")), CanCreateSubTokens: true, AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, @@ -85,8 +85,8 @@ func TestTokenMarshal(t *testing.T) { ID: "deadbeef-dead-beef-dead-beefdeadbeef", Name: "test_token", Type: TokenTypeWorkspace, - Created: timeParse("2024-12-19T11:46:09.888763Z"), - Expires: timeParse("2024-12-19T12:16:09.888531Z"), + CreatedAt: timeParse("2024-12-19T11:46:09.888763Z"), + ExpiresAt: timeParse("2024-12-19T12:16:09.888531Z"), LastUsed: nil, CanCreateSubTokens: true, AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, From a0e799810bb2fd1526deb6840f89b546bb29e954 Mon Sep 17 00:00:00 2001 From: riku salkia Date: Fri, 20 Dec 2024 14:40:11 +0200 Subject: [PATCH 04/16] feat(account): add token api fixtures --- upcloud/service/fixtures/token.yaml | 143 ++++++++++++++++++++++++++++ upcloud/service/token_test.go | 2 + 2 files changed, 145 insertions(+) create mode 100644 upcloud/service/fixtures/token.yaml diff --git a/upcloud/service/fixtures/token.yaml b/upcloud/service/fixtures/token.yaml new file mode 100644 index 0000000..c6c1b7a --- /dev/null +++ b/upcloud/service/fixtures/token.yaml @@ -0,0 +1,143 @@ +--- +version: 1 +interactions: +- request: + body: '{"name":"my_1st_token","expires_at":"2025-12-01T00:00:00Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - upcloud-go-api/8.14.0 + url: https://api.upcloud.com/1.3/account/tokens + method: POST + response: + body: '{"token":"ucat_01JFJ0HDPBXE0DJBP9DXNBEGGP","id":"0c0cb933-d5d5-4027-a4f5-20019f30a913","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T12:26:36.619315Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false}' + headers: + Content-Length: + - "292" + Content-Type: + - application/json + Date: + - Fri, 20 Dec 2024 12:26:36 GMT + status: 201 Created + code: 201 + duration: "" +- request: + body: '{"name":"my_2nd_token","expires_at":"2025-12-01T00:00:00Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/1","::/0"]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - upcloud-go-api/8.14.0 + url: https://api.upcloud.com/1.3/account/tokens + method: POST + response: + body: '{"token":"ucat_01JFJ0HDR7EHQVZER2KZXBR5NC","id":"0c54f4bf-0b31-47da-b9f5-5cebeda621a4","name":"my_2nd_token","type":"workspace","created_at":"2024-12-20T12:26:36.679304Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/1","::/0"],"gui":false}' + headers: + Content-Length: + - "293" + Content-Type: + - application/json + Date: + - Fri, 20 Dec 2024 12:26:36 GMT + status: 201 Created + code: 201 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - upcloud-go-api/8.14.0 + url: https://api.upcloud.com/1.3/account/tokens/0c0cb933-d5d5-4027-a4f5-20019f30a913 + method: GET + response: + body: '{"id":"0c0cb933-d5d5-4027-a4f5-20019f30a913","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T12:26:36.619315Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false}' + headers: + Content-Length: + - "250" + Content-Type: + - application/json + Date: + - Fri, 20 Dec 2024 12:26:36 GMT + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - upcloud-go-api/8.14.0 + url: https://api.upcloud.com/1.3/account/tokens + method: GET + response: + body: '[{"id":"0c2adaf6-0805-4f18-bb45-03fce1bc1c2d","name":"token","type":"workspace","created_at":"2024-12-19T11:46:09.888763Z","expires_at":"2024-12-19T12:16:09.888531Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c1a21dc-11b0-47aa-9979-b45e03b38788","name":"token","type":"workspace","created_at":"2024-12-19T11:57:33.40507Z","expires_at":"2024-12-19T12:27:33.404897Z","last_used_at":"2024-12-19T12:01:13.538016Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c09ff1a-aec0-4a22-bbca-01970a710ecc","name":"token","type":"workspace","created_at":"2024-12-19T12:17:17.14145Z","expires_at":"2024-12-19T12:47:17.141249Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0cc7fdd4-af6f-4da4-9a59-a7b923d27317","name":"my_1st_token","type":"workspace","created_at":"2024-12-19T13:14:40.617399Z","expires_at":"2024-12-19T14:14:40.532908Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c0d735c-4414-46f1-a1e8-64bb27667196","name":"my_1st_token","type":"workspace","created_at":"2024-12-19T13:17:35.961859Z","expires_at":"2024-12-19T14:17:35.845322Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0cd3aa86-b1ed-416b-82a9-427778762d43","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T07:44:49.162896Z","expires_at":"2024-12-20T08:44:49.066726Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c3869ea-bb43-4588-a16d-02d07a394d94","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T07:49:59.192905Z","expires_at":"2025-01-01T00:00:00.000012Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c0cb933-d5d5-4027-a4f5-20019f30a913","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T12:26:36.619315Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c54f4bf-0b31-47da-b9f5-5cebeda621a4","name":"my_2nd_token","type":"workspace","created_at":"2024-12-20T12:26:36.679304Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/1","::/0"],"gui":false}]' + headers: + Content-Length: + - "2285" + Content-Type: + - application/json + Date: + - Fri, 20 Dec 2024 12:26:36 GMT + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"name":"my_1st_token","expires_at":"2025-12-01T00:00:00Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - upcloud-go-api/8.14.0 + url: https://api.upcloud.com/1.3/account/tokens + method: POST + response: + body: '{"token":"ucat_01JFJ0HDVC5NQFFD0BYE5N0VBG","id":"0c62c862-2f4a-41d3-8f07-1cfbded87295","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T12:26:36.78057Z","expires_at":"2025-12-01T00:00:00.000018Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false}' + headers: + Content-Length: + - "291" + Content-Type: + - application/json + Date: + - Fri, 20 Dec 2024 12:26:36 GMT + status: 201 Created + code: 201 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - upcloud-go-api/8.14.0 + url: https://api.upcloud.com/1.3/account/tokens/0c62c862-2f4a-41d3-8f07-1cfbded87295 + method: DELETE + response: + body: "" + headers: + Date: + - Fri, 20 Dec 2024 12:26:36 GMT + status: 204 No Content + code: 204 + duration: "" diff --git a/upcloud/service/token_test.go b/upcloud/service/token_test.go index 734ca73..11d522d 100644 --- a/upcloud/service/token_test.go +++ b/upcloud/service/token_test.go @@ -31,6 +31,8 @@ func TestToken(t *testing.T) { } record(t, "token", func(ctx context.Context, t *testing.T, rec *recorder.Recorder, svc *Service) { + // TODO: obfuscate real tokes from fixtures. Currently committed tokens in token.yaml are from local env + // with the url changed to prod host. rec.AddFilter() for the win. // Create some tokens ids := make([]string, len(tokenRequests)) From b41b952b9fb4bbc3cbc1ba82bbe68c7a664f6e0a Mon Sep 17 00:00:00 2001 From: riku salkia Date: Thu, 9 Jan 2025 14:52:05 +0200 Subject: [PATCH 05/16] feat(tokens): add paging support --- upcloud/request/token.go | 10 +++++++++- upcloud/request/token_test.go | 6 ++++++ upcloud/service/token.go | 5 ++--- upcloud/service/token_test.go | 5 +---- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/upcloud/request/token.go b/upcloud/request/token.go index e8590ed..6bd993c 100644 --- a/upcloud/request/token.go +++ b/upcloud/request/token.go @@ -17,9 +17,17 @@ func (r *GetTokenDetailsRequest) RequestURL() string { } // GetTokensRequest represents a request to get a list of tokens. Will not return the actual API tokens. -type GetTokensRequest struct{} +type GetTokensRequest struct { + Page *Page +} func (r *GetTokensRequest) RequestURL() string { + if r.Page != nil { + f := make([]QueryFilter, 0) + f = append(f, r.Page) + return fmt.Sprintf("%s?%s", basePath, encodeQueryFilters(f)) + } + return basePath } diff --git a/upcloud/request/token_test.go b/upcloud/request/token_test.go index 2c76eb3..1c71c72 100644 --- a/upcloud/request/token_test.go +++ b/upcloud/request/token_test.go @@ -15,6 +15,12 @@ func TestGetTokenDetailsRequest(t *testing.T) { func TestGetTokensRequest(t *testing.T) { assert.Equal(t, "/account/tokens", (&GetTokensRequest{}).RequestURL()) assert.Equal(t, "/account/tokens", (&GetTokensRequest{}).RequestURL()) + assert.Equal(t, "/account/tokens?limit=10&offset=10", (&GetTokensRequest{ + Page: &Page{ + Size: 10, + Number: 2, + }, + }).RequestURL()) } func TestDeleteTokenRequest(t *testing.T) { diff --git a/upcloud/service/token.go b/upcloud/service/token.go index cef9249..258a21c 100644 --- a/upcloud/service/token.go +++ b/upcloud/service/token.go @@ -10,7 +10,7 @@ import ( type Token interface { CreateToken(context.Context, *request.CreateTokenRequest) (*upcloud.Token, error) GetTokenDetails(ctx context.Context, r *request.GetTokenDetailsRequest) (*upcloud.Token, error) - GetTokens(context.Context) (*upcloud.Tokens, error) + GetTokens(context.Context, *request.GetTokensRequest) (*upcloud.Tokens, error) DeleteToken(context.Context, *request.DeleteTokenRequest) error } @@ -27,8 +27,7 @@ func (s *Service) GetTokenDetails(ctx context.Context, r *request.GetTokenDetail } // GetTokens returns the all the available networks -func (s *Service) GetTokens(ctx context.Context) (*upcloud.Tokens, error) { - req := request.GetTokensRequest{} +func (s *Service) GetTokens(ctx context.Context, req *request.GetTokensRequest) (*upcloud.Tokens, error) { tokens := upcloud.Tokens{} return &tokens, s.get(ctx, req.RequestURL(), &tokens) } diff --git a/upcloud/service/token_test.go b/upcloud/service/token_test.go index 11d522d..8e87b6b 100644 --- a/upcloud/service/token_test.go +++ b/upcloud/service/token_test.go @@ -59,10 +59,7 @@ func TestToken(t *testing.T) { assert.Equal(t, true, token.CanCreateSubTokens) // List tokens - // TODO: paging. Currently lists up to 100 tokens and does not support paging. Do we want to add explicit Page - // parameter to the request, or within client request all the tokens page by page and return one possibly - // massive list? I'd go with explicit paging. - tokens, err := svc.GetTokens(ctx) + tokens, err := svc.GetTokens(ctx, &request.GetTokensRequest{}) require.NoError(t, err) require.GreaterOrEqual(t, len(*tokens), len(tokenRequests)) From 22c55bc18bb34a8efe9f51d3e28b89d89314148a Mon Sep 17 00:00:00 2001 From: riku salkia Date: Fri, 10 Jan 2025 12:08:45 +0200 Subject: [PATCH 06/16] feat(tokens): add bearer token auth support --- examples/upcloud-cli/main.go | 28 +++++++++++++++++++--------- upcloud/client/client.go | 27 ++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/examples/upcloud-cli/main.go b/examples/upcloud-cli/main.go index 5e0a922..17c305b 100644 --- a/examples/upcloud-cli/main.go +++ b/examples/upcloud-cli/main.go @@ -14,11 +14,12 @@ import ( "github.com/davecgh/go-spew/spew" ) -var username, password string +var username, password, token string func init() { flag.StringVar(&username, "username", "", "UpCloud username") flag.StringVar(&password, "password", "", "UpCloud password") + flag.StringVar(&password, "token", "", "UpCloud API token") } func main() { @@ -34,21 +35,30 @@ func run() int { if username == "" { username = os.Getenv("UPCLOUD_USERNAME") } + if token == "" { + token = os.Getenv("UPCLOUD_TOKEN") + } command := flag.Arg(0) - if len(username) == 0 { - fmt.Fprintln(os.Stderr, "Username must be specified") - return 1 - } + var authCfg client.ConfigFn + if len(token) > 0 { + authCfg = client.WithBearerAuth(token) + } else { + if len(username) == 0 { + fmt.Fprintln(os.Stderr, "Username or token must be specified") + return 1 + } - if len(password) == 0 { - fmt.Fprintln(os.Stderr, "Password must be specified") - return 2 + if len(password) == 0 { + fmt.Fprintln(os.Stderr, "Password or token must be specified") + return 2 + } + authCfg = client.WithBasicAuth(username, password) } fmt.Println("Creating new client") - c := client.New(username, password) + c := client.New("", "", authCfg) s := service.New(c) switch command { diff --git a/upcloud/client/client.go b/upcloud/client/client.go index 29a6acc..81c982d 100644 --- a/upcloud/client/client.go +++ b/upcloud/client/client.go @@ -30,6 +30,7 @@ type LogFn func(context.Context, string, ...any) type config struct { username string password string + token string baseURL string httpClient *http.Client logger LogFn @@ -152,7 +153,11 @@ func (c *Client) addDefaultHeaders(r *http.Request) { r.Header.Set(userAgent, c.UserAgent) } if _, ok := r.Header[authorization]; !ok && strings.HasPrefix(r.URL.String(), c.config.baseURL) { - r.SetBasicAuth(c.config.username, c.config.password) + if c.config.token != "" { + r.Header.Set(authorization, "Bearer "+c.config.token) + } else { + r.SetBasicAuth(c.config.username, c.config.password) + } } } @@ -248,6 +253,24 @@ func WithHTTPClient(httpClient *http.Client) ConfigFn { } } +// WithBasicAuth configures the client to use basic auth credentials for authentication +func WithBasicAuth(username, password string) ConfigFn { + return func(c *config) { + c.username = username + c.password = password + c.token = "" + } +} + +// WithBearerAuth configures the client to use bearer token for authentication +func WithBearerAuth(apiToken string) ConfigFn { + return func(c *config) { + c.token = apiToken + c.username = "" + c.password = "" + } +} + // WithTimeout modifies the client's httpClient timeout func WithTimeout(timeout time.Duration) ConfigFn { return func(c *config) { @@ -264,6 +287,8 @@ func WithLogger(logger LogFn) ConfigFn { // New creates and returns a new client configured with the specified user and password and optional // config functions. +// TODO: we should get rid of username, password here, but it's a breaking change. Credentials can be now set with +// configurators client.WithBasicAuth("user", "pass") or client.WithBearerAuth("ucat_token") func New(username, password string, c ...ConfigFn) *Client { config := config{ username: username, From 20676f0b52ef0db67f7df3d7ce84822c92eb0091 Mon Sep 17 00:00:00 2001 From: Francisco Serrano Date: Fri, 31 Jan 2025 11:06:23 +0200 Subject: [PATCH 07/16] Support for bearer token and first test --- upcloud/service/token_test.go | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/upcloud/service/token_test.go b/upcloud/service/token_test.go index 8e87b6b..1605f20 100644 --- a/upcloud/service/token_test.go +++ b/upcloud/service/token_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/client" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" "github.com/dnaeon/go-vcr/recorder" "github.com/stretchr/testify/assert" @@ -70,6 +71,50 @@ func TestToken(t *testing.T) { }) } +func TestClientWithToken(t *testing.T) { + expires := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) + tokenRequest := request.CreateTokenRequest{ + Name: "my_1st_token", + ExpiresAt: expires, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + CanCreateSubTokens: true, + } + + // Create client that retries the initial token + user, password := getCredentials() + clt := client.New(user, password) + svc := New(clt) + + // Get the initial token + token, err := svc.CreateToken(context.Background(), &tokenRequest) + require.NoError(t, err) + + // Create a new client with the initial token + authCfg := client.WithBearerAuth(token.APIToken) + cltWithToken := client.New("", "", authCfg) + + // Create a new service with the client with the initial token + svcWithToken := New(cltWithToken) + + account, err := svcWithToken.GetAccount(context.Background()) + require.NoError(t, err) + + // Print account details + t.Logf("Account: %+v", account) + + if account.UserName != user { + t.Errorf("TestGetAccount expected %s, got %s", user, account.UserName) + } + + assert.NotZero(t, account.ResourceLimits.Cores) + assert.NotZero(t, account.ResourceLimits.Memory) + assert.NotZero(t, account.ResourceLimits.Networks) + assert.NotZero(t, account.ResourceLimits.PublicIPv6) + assert.NotZero(t, account.ResourceLimits.StorageHDD) + assert.NotZero(t, account.ResourceLimits.StorageSSD) + +} + func cleanupTokenFunc(t *testing.T, svc *Service, id string) func() { return func() { if err := svc.DeleteToken(context.Background(), &request.DeleteTokenRequest{ID: id}); err != nil { From 2b90cb29c083228ae8657c37e9e6be4f9008533d Mon Sep 17 00:00:00 2001 From: Francisco Serrano Date: Fri, 31 Jan 2025 11:10:43 +0200 Subject: [PATCH 08/16] chore(tokens): Fixing a linting error --- upcloud/service/token_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/upcloud/service/token_test.go b/upcloud/service/token_test.go index 1605f20..07841f1 100644 --- a/upcloud/service/token_test.go +++ b/upcloud/service/token_test.go @@ -112,7 +112,6 @@ func TestClientWithToken(t *testing.T) { assert.NotZero(t, account.ResourceLimits.PublicIPv6) assert.NotZero(t, account.ResourceLimits.StorageHDD) assert.NotZero(t, account.ResourceLimits.StorageSSD) - } func cleanupTokenFunc(t *testing.T, svc *Service, id string) func() { From 125be91f5d095adc6c1d73bb5543a18bf4352892 Mon Sep 17 00:00:00 2001 From: Francisco Serrano Date: Fri, 31 Jan 2025 11:24:09 +0200 Subject: [PATCH 09/16] chore: Updating CHANGELOG.md and adding EXPERIMENTAL --- CHANGELOG.md | 3 +++ upcloud/client/client.go | 2 +- upcloud/request/token.go | 14 ++++++++------ upcloud/service/token.go | 8 ++++---- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0efaa75..7b92312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ See updating [Changelog example here](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +### Added +- Added support for token-based authentication in the client, including functions for token management. + ## [8.14.0] ### Added diff --git a/upcloud/client/client.go b/upcloud/client/client.go index 81c982d..9aaab62 100644 --- a/upcloud/client/client.go +++ b/upcloud/client/client.go @@ -262,7 +262,7 @@ func WithBasicAuth(username, password string) ConfigFn { } } -// WithBearerAuth configures the client to use bearer token for authentication +// WithBearerAuth (EXPERIMENTAL) configures the client to use bearer token for authentication func WithBearerAuth(apiToken string) ConfigFn { return func(c *config) { c.token = apiToken diff --git a/upcloud/request/token.go b/upcloud/request/token.go index 6bd993c..eee1845 100644 --- a/upcloud/request/token.go +++ b/upcloud/request/token.go @@ -7,20 +7,22 @@ import ( const basePath = "/account/tokens" -// GetTokenDetailsRequest represents a request to get token details. Will not return the actual API token. +// GetTokenDetailsRequest (EXPERIMENTAL) represents a request to get token details. Will not return the actual API token. type GetTokenDetailsRequest struct { ID string } +// RequestURL (EXPERIMENTAL) implements the Request interface. func (r *GetTokenDetailsRequest) RequestURL() string { return fmt.Sprintf("%s/%s", basePath, r.ID) } -// GetTokensRequest represents a request to get a list of tokens. Will not return the actual API tokens. +// GetTokensRequest (EXPERIMENTAL) represents a request to get a list of tokens. Will not return the actual API tokens. type GetTokensRequest struct { Page *Page } +// RequestURL (EXPERIMENTAL) implements the Request interface. func (r *GetTokensRequest) RequestURL() string { if r.Page != nil { f := make([]QueryFilter, 0) @@ -31,7 +33,7 @@ func (r *GetTokensRequest) RequestURL() string { return basePath } -// CreateTokenRequest represents a request to create a new network. +// CreateTokenRequest (EXPERIMENTAL) represents a request to create a new network. type CreateTokenRequest struct { Name string `json:"name"` ExpiresAt time.Time `json:"expires_at"` @@ -39,17 +41,17 @@ type CreateTokenRequest struct { AllowedIPRanges []string `json:"allowed_ip_ranges"` } -// RequestURL implements the Request interface. +// RequestURL (EXPERIMENTAL) implements the Request interface. func (r *CreateTokenRequest) RequestURL() string { return basePath } -// DeleteTokenRequest represents a request to delete a token. +// DeleteTokenRequest (EXPERIMENTAL) represents a request to delete a token. type DeleteTokenRequest struct { ID string } -// RequestURL implements the Request interface. +// RequestURL (EXPERIMENTAL) implements the Request interface. func (r *DeleteTokenRequest) RequestURL() string { return fmt.Sprintf("%s/%s", basePath, r.ID) } diff --git a/upcloud/service/token.go b/upcloud/service/token.go index 258a21c..014c88b 100644 --- a/upcloud/service/token.go +++ b/upcloud/service/token.go @@ -14,25 +14,25 @@ type Token interface { DeleteToken(context.Context, *request.DeleteTokenRequest) error } -// CreateToken creates a new token. +// CreateToken (EXPERIMENTAL) creates a new token. func (s *Service) CreateToken(ctx context.Context, r *request.CreateTokenRequest) (*upcloud.Token, error) { token := upcloud.Token{} return &token, s.create(ctx, r, &token) } -// GetTokenDetails returns the details for the specified token. +// GetTokenDetails (EXPERIMENTAL) returns the details for the specified token. func (s *Service) GetTokenDetails(ctx context.Context, r *request.GetTokenDetailsRequest) (*upcloud.Token, error) { token := upcloud.Token{} return &token, s.get(ctx, r.RequestURL(), &token) } -// GetTokens returns the all the available networks +// GetTokens (EXPERIMENTAL) returns the all the available networks func (s *Service) GetTokens(ctx context.Context, req *request.GetTokensRequest) (*upcloud.Tokens, error) { tokens := upcloud.Tokens{} return &tokens, s.get(ctx, req.RequestURL(), &tokens) } -// DeleteToken deletes the specified token. +// DeleteToken (EXPERIMENTAL) deletes the specified token. func (s *Service) DeleteToken(ctx context.Context, r *request.DeleteTokenRequest) error { return s.delete(ctx, r) } From c7a5460c8b560de255bd5fb56aaca72e716e33a0 Mon Sep 17 00:00:00 2001 From: Francisco Serrano Date: Thu, 6 Feb 2025 17:39:58 +0200 Subject: [PATCH 10/16] feat(tokens): Adding test and token filter in fixture --- upcloud/service/fixtures/token.yaml | 58 +++++++++++++++++-------- upcloud/service/token_test.go | 67 ++++++++++++++--------------- upcloud/service/utils_test.go | 60 ++++++++++++++++++++++++-- 3 files changed, 131 insertions(+), 54 deletions(-) diff --git a/upcloud/service/fixtures/token.yaml b/upcloud/service/fixtures/token.yaml index c6c1b7a..ed9475b 100644 --- a/upcloud/service/fixtures/token.yaml +++ b/upcloud/service/fixtures/token.yaml @@ -7,6 +7,8 @@ interactions: headers: Accept: - application/json + Authorization: + - Basic [REDACTED] Content-Type: - application/json User-Agent: @@ -14,14 +16,16 @@ interactions: url: https://api.upcloud.com/1.3/account/tokens method: POST response: - body: '{"token":"ucat_01JFJ0HDPBXE0DJBP9DXNBEGGP","id":"0c0cb933-d5d5-4027-a4f5-20019f30a913","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T12:26:36.619315Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false}' + body: '{"allowed_ip_ranges":["0.0.0.0/0","::/0"],"can_create_tokens":true,"created_at":"2025-02-06T15:33:07.534408Z","expires_at":"2025-12-01T00:00:00.00001Z","gui":false,"id":"0c511c65-5375-4dd3-8a76-df12ba772110","name":"my_1st_token","token":"ucat_[REDACTED]","type":"workspace"}' headers: Content-Length: - - "292" + - "291" Content-Type: - application/json Date: - - Fri, 20 Dec 2024 12:26:36 GMT + - Thu, 06 Feb 2025 15:33:07 GMT + Strict-Transport-Security: + - max-age=63072000 status: 201 Created code: 201 duration: "" @@ -31,6 +35,8 @@ interactions: headers: Accept: - application/json + Authorization: + - Basic [REDACTED] Content-Type: - application/json User-Agent: @@ -38,14 +44,16 @@ interactions: url: https://api.upcloud.com/1.3/account/tokens method: POST response: - body: '{"token":"ucat_01JFJ0HDR7EHQVZER2KZXBR5NC","id":"0c54f4bf-0b31-47da-b9f5-5cebeda621a4","name":"my_2nd_token","type":"workspace","created_at":"2024-12-20T12:26:36.679304Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/1","::/0"],"gui":false}' + body: '{"allowed_ip_ranges":["0.0.0.0/1","::/0"],"can_create_tokens":false,"created_at":"2025-02-06T15:33:07.895253Z","expires_at":"2025-12-01T00:00:00.000012Z","gui":false,"id":"0c26b13f-8079-4415-889d-6e4e6fba6de1","name":"my_2nd_token","token":"ucat_[REDACTED]","type":"workspace"}' headers: Content-Length: - "293" Content-Type: - application/json Date: - - Fri, 20 Dec 2024 12:26:36 GMT + - Thu, 06 Feb 2025 15:33:07 GMT + Strict-Transport-Security: + - max-age=63072000 status: 201 Created code: 201 duration: "" @@ -55,21 +63,25 @@ interactions: headers: Accept: - application/json + Authorization: + - Basic [REDACTED] Content-Type: - application/json User-Agent: - upcloud-go-api/8.14.0 - url: https://api.upcloud.com/1.3/account/tokens/0c0cb933-d5d5-4027-a4f5-20019f30a913 + url: https://api.upcloud.com/1.3/account/tokens/0c511c65-5375-4dd3-8a76-df12ba772110 method: GET response: - body: '{"id":"0c0cb933-d5d5-4027-a4f5-20019f30a913","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T12:26:36.619315Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false}' + body: '{"allowed_ip_ranges":["0.0.0.0/0","::/0"],"can_create_tokens":true,"created_at":"2025-02-06T15:33:07.534408Z","expires_at":"2025-12-01T00:00:00.00001Z","gui":false,"id":"0c511c65-5375-4dd3-8a76-df12ba772110","name":"my_1st_token","type":"workspace"}' headers: Content-Length: - - "250" + - "249" Content-Type: - application/json Date: - - Fri, 20 Dec 2024 12:26:36 GMT + - Thu, 06 Feb 2025 15:33:08 GMT + Strict-Transport-Security: + - max-age=63072000 status: 200 OK code: 200 duration: "" @@ -79,6 +91,8 @@ interactions: headers: Accept: - application/json + Authorization: + - Basic [REDACTED] Content-Type: - application/json User-Agent: @@ -86,14 +100,16 @@ interactions: url: https://api.upcloud.com/1.3/account/tokens method: GET response: - body: '[{"id":"0c2adaf6-0805-4f18-bb45-03fce1bc1c2d","name":"token","type":"workspace","created_at":"2024-12-19T11:46:09.888763Z","expires_at":"2024-12-19T12:16:09.888531Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c1a21dc-11b0-47aa-9979-b45e03b38788","name":"token","type":"workspace","created_at":"2024-12-19T11:57:33.40507Z","expires_at":"2024-12-19T12:27:33.404897Z","last_used_at":"2024-12-19T12:01:13.538016Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c09ff1a-aec0-4a22-bbca-01970a710ecc","name":"token","type":"workspace","created_at":"2024-12-19T12:17:17.14145Z","expires_at":"2024-12-19T12:47:17.141249Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0cc7fdd4-af6f-4da4-9a59-a7b923d27317","name":"my_1st_token","type":"workspace","created_at":"2024-12-19T13:14:40.617399Z","expires_at":"2024-12-19T14:14:40.532908Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c0d735c-4414-46f1-a1e8-64bb27667196","name":"my_1st_token","type":"workspace","created_at":"2024-12-19T13:17:35.961859Z","expires_at":"2024-12-19T14:17:35.845322Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0cd3aa86-b1ed-416b-82a9-427778762d43","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T07:44:49.162896Z","expires_at":"2024-12-20T08:44:49.066726Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c3869ea-bb43-4588-a16d-02d07a394d94","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T07:49:59.192905Z","expires_at":"2025-01-01T00:00:00.000012Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c0cb933-d5d5-4027-a4f5-20019f30a913","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T12:26:36.619315Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c54f4bf-0b31-47da-b9f5-5cebeda621a4","name":"my_2nd_token","type":"workspace","created_at":"2024-12-20T12:26:36.679304Z","expires_at":"2025-12-01T00:00:00.000013Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/1","::/0"],"gui":false}]' + body: '[{"id":"0cd85ae7-0a16-423c-817b-85b8f4bcbb03","name":"my_1st_token","type":"workspace","created_at":"2025-01-30T17:40:59.267513Z","expires_at":"2025-12-01T00:00:00.000009Z","last_used_at":"2025-01-30T17:40:59.50002Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0ca1d997-a25f-427a-af81-3911b0d92912","name":"my_1st_token","type":"workspace","created_at":"2025-01-31T08:59:43.218102Z","expires_at":"2025-12-01T00:00:00.00001Z","last_used_at":"2025-01-31T08:59:43.499057Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c511c65-5375-4dd3-8a76-df12ba772110","name":"my_1st_token","type":"workspace","created_at":"2025-02-06T15:33:07.534408Z","expires_at":"2025-12-01T00:00:00.00001Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false},{"id":"0c26b13f-8079-4415-889d-6e4e6fba6de1","name":"my_2nd_token","type":"workspace","created_at":"2025-02-06T15:33:07.895253Z","expires_at":"2025-12-01T00:00:00.000012Z","can_create_tokens":false,"allowed_ip_ranges":["0.0.0.0/1","::/0"],"gui":false}]' headers: Content-Length: - - "2285" + - "1093" Content-Type: - application/json Date: - - Fri, 20 Dec 2024 12:26:36 GMT + - Thu, 06 Feb 2025 15:33:08 GMT + Strict-Transport-Security: + - max-age=63072000 status: 200 OK code: 200 duration: "" @@ -103,6 +119,8 @@ interactions: headers: Accept: - application/json + Authorization: + - Basic [REDACTED] Content-Type: - application/json User-Agent: @@ -110,14 +128,16 @@ interactions: url: https://api.upcloud.com/1.3/account/tokens method: POST response: - body: '{"token":"ucat_01JFJ0HDVC5NQFFD0BYE5N0VBG","id":"0c62c862-2f4a-41d3-8f07-1cfbded87295","name":"my_1st_token","type":"workspace","created_at":"2024-12-20T12:26:36.78057Z","expires_at":"2025-12-01T00:00:00.000018Z","can_create_tokens":true,"allowed_ip_ranges":["0.0.0.0/0","::/0"],"gui":false}' + body: '{"allowed_ip_ranges":["0.0.0.0/0","::/0"],"can_create_tokens":true,"created_at":"2025-02-06T15:33:09.092616Z","expires_at":"2025-12-01T00:00:00.000008Z","gui":false,"id":"0ce0d95e-a0ac-4b99-af2c-392da20ef103","name":"my_1st_token","token":"ucat_[REDACTED]","type":"workspace"}' headers: Content-Length: - - "291" + - "292" Content-Type: - application/json Date: - - Fri, 20 Dec 2024 12:26:36 GMT + - Thu, 06 Feb 2025 15:33:09 GMT + Strict-Transport-Security: + - max-age=63072000 status: 201 Created code: 201 duration: "" @@ -127,17 +147,21 @@ interactions: headers: Accept: - application/json + Authorization: + - Basic [REDACTED] Content-Type: - application/json User-Agent: - upcloud-go-api/8.14.0 - url: https://api.upcloud.com/1.3/account/tokens/0c62c862-2f4a-41d3-8f07-1cfbded87295 + url: https://api.upcloud.com/1.3/account/tokens/0ce0d95e-a0ac-4b99-af2c-392da20ef103 method: DELETE response: body: "" headers: Date: - - Fri, 20 Dec 2024 12:26:36 GMT + - Thu, 06 Feb 2025 15:33:09 GMT + Strict-Transport-Security: + - max-age=63072000 status: 204 No Content code: 204 duration: "" diff --git a/upcloud/service/token_test.go b/upcloud/service/token_test.go index 07841f1..eb402be 100644 --- a/upcloud/service/token_test.go +++ b/upcloud/service/token_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "os" "strings" "testing" "time" @@ -32,8 +33,6 @@ func TestToken(t *testing.T) { } record(t, "token", func(ctx context.Context, t *testing.T, rec *recorder.Recorder, svc *Service) { - // TODO: obfuscate real tokes from fixtures. Currently committed tokens in token.yaml are from local env - // with the url changed to prod host. rec.AddFilter() for the win. // Create some tokens ids := make([]string, len(tokenRequests)) @@ -71,7 +70,11 @@ func TestToken(t *testing.T) { }) } +// TestClientWithToken tests that a client can be created with a token and used to make authenticated API requests func TestClientWithToken(t *testing.T) { + if os.Getenv("UPCLOUD_GO_SDK_TEST_NO_CREDENTIALS") == "yes" || testing.Short() { + t.Skip("Skipping TestGetAccount...") + } expires := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) tokenRequest := request.CreateTokenRequest{ Name: "my_1st_token", @@ -79,45 +82,41 @@ func TestClientWithToken(t *testing.T) { AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, CanCreateSubTokens: true, } - - // Create client that retries the initial token - user, password := getCredentials() - clt := client.New(user, password) - svc := New(clt) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() // Get the initial token - token, err := svc.CreateToken(context.Background(), &tokenRequest) - require.NoError(t, err) - - // Create a new client with the initial token - authCfg := client.WithBearerAuth(token.APIToken) - cltWithToken := client.New("", "", authCfg) - - // Create a new service with the client with the initial token - svcWithToken := New(cltWithToken) - - account, err := svcWithToken.GetAccount(context.Background()) - require.NoError(t, err) - - // Print account details - t.Logf("Account: %+v", account) - - if account.UserName != user { - t.Errorf("TestGetAccount expected %s, got %s", user, account.UserName) - } - - assert.NotZero(t, account.ResourceLimits.Cores) - assert.NotZero(t, account.ResourceLimits.Memory) - assert.NotZero(t, account.ResourceLimits.Networks) - assert.NotZero(t, account.ResourceLimits.PublicIPv6) - assert.NotZero(t, account.ResourceLimits.StorageHDD) - assert.NotZero(t, account.ResourceLimits.StorageSSD) + user, password := getCredentials() + svc := New(client.New(user, password)) + token, err := svc.CreateToken(ctx, &tokenRequest) + require.NoError(t, err, "Failed to create token") + require.NotNil(t, token, "Token must not be nil") + + // Make sure that we cleanup the token + t.Cleanup(cleanupTokenFunc(t, svc, token.ID)) + + // Create a new client with the token + svcWithToken := New(client.New("", "", client.WithBearerAuth(token.APIToken))) + + // Make an authenticated API request + server, err := svcWithToken.GetServers(ctx) + require.NotEmpty(t, server, "Failed to get servers. This points to a problem with token auth") + require.NoError(t, err, "Error getting the servers. This points to a problem with token auth") + + //Delete the token + err = svcWithToken.DeleteToken(ctx, &request.DeleteTokenRequest{ID: token.ID}) + require.NoError(t, err, "Token deletion should not fail") + + // Make sure the token is deleted + server, err = svcWithToken.GetServers(ctx) + require.Error(t, err, "Getting servers with deleted token should fail") + require.Empty(t, server, "Getting servers with deleted token should return empty list") } func cleanupTokenFunc(t *testing.T, svc *Service, id string) func() { return func() { if err := svc.DeleteToken(context.Background(), &request.DeleteTokenRequest{ID: id}); err != nil { - t.Log(err) + t.Log(err, "This might not be a problem if the test deleted the token already") } } } diff --git a/upcloud/service/utils_test.go b/upcloud/service/utils_test.go index 9571f00..1119900 100644 --- a/upcloud/service/utils_test.go +++ b/upcloud/service/utils_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "log" "net/http" "net/http/httptest" @@ -28,8 +29,18 @@ func (c *customRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) return c.fn(r) } +// getTokenCredentials reads the token credential from the environment +func getTokenCredentials() string { + return os.Getenv("UPCLOUD_GO_SDK_TOKEN") +} + // Reads the API username and password from the environment, panics if they are not available. func getCredentials() (string, string) { + // Read UPCLOUD_GO_SDK_TOKEN environment variable + if token := os.Getenv("UPCLOUD_GO_SDK_TOKEN"); token != "" { + return token, "" + } + if os.Getenv("UPCLOUD_GO_SDK_TEST_NO_CREDENTIALS") == "yes" { return "username", "password" } @@ -60,8 +71,42 @@ func record(t *testing.T, fixture string, f func(context.Context, *testing.T, *r r, err := recorder.New("fixtures/" + fixture) require.NoError(t, err) + // Redact sensitive information from Authorization field r.AddFilter(func(i *cassette.Interaction) error { - delete(i.Request.Headers, "Authorization") + if authHeader, ok := i.Request.Headers["Authorization"]; ok { + var redactedAuthHeader []string + for _, value := range authHeader { + if strings.HasPrefix(value, "Bearer ") { + redactedAuthHeader = append(redactedAuthHeader, "Bearer [REDACTED]") + } else if strings.HasPrefix(value, "Basic ") { + redactedAuthHeader = append(redactedAuthHeader, "Basic [REDACTED]") + } + } + + if len(redactedAuthHeader) > 0 { + i.Request.Headers["Authorization"] = redactedAuthHeader + } else { + delete(i.Request.Headers, "Authorization") + } + } + + // Redact sensitive information from response body + if i.Response.Body != "" { + var responseData map[string]interface{} + + err := json.Unmarshal([]byte(i.Response.Body), &responseData) + if err == nil { + // Redact sensitive fields + if _, exists := responseData["token"]; exists { + responseData["token"] = "ucat_[REDACTED]" + } + + // Convert back to string and update response body + updatedBody, _ := json.Marshal(responseData) + i.Response.Body = string(updatedBody) + } + } + if i.Request.Method == http.MethodPut && strings.Contains(i.Request.URL, "uploader") { // We will remove the body from the upload to reduce fixture size i.Request.Body = "" @@ -74,7 +119,12 @@ func record(t *testing.T, fixture string, f func(context.Context, *testing.T, *r require.NoError(t, err) }() - user, password := getCredentials() + //Read token credentials from the environment, if it does not exists try to read user and password + var user, password string + token := getTokenCredentials() + if token == "" { + user, password = getCredentials() + } httpClient := client.NewDefaultHTTPClient() origTransport := httpClient.Transport @@ -95,7 +145,11 @@ func record(t *testing.T, fixture string, f func(context.Context, *testing.T, *r // just some random timeout value. High enough that it won't be reached during normal test. ctx, cancel := context.WithTimeout(context.Background(), waitTimeout*4) defer cancel() - f(ctx, t, r, New(client.New(user, password, client.WithHTTPClient(httpClient)))) + if token == "" { + f(ctx, t, r, New(client.New(user, password, client.WithHTTPClient(httpClient)))) + } else { + f(ctx, t, r, New(client.New("", "", client.WithBearerAuth(token), client.WithHTTPClient(httpClient)))) + } } // Tears down the test environment by removing all resources. From a1bcd9f9c7acb456c63dae76cebbe1d3c10670a4 Mon Sep 17 00:00:00 2001 From: Francisco Serrano Date: Thu, 6 Feb 2025 17:55:00 +0200 Subject: [PATCH 11/16] Fix linter warning --- .golangci.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 657f270..42e3eb8 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -37,10 +37,8 @@ linters: - wastedassign - whitespace -output: - uniq-by-line: false - issues: + uniq-by-line: false max-issues-per-linter: 0 max-same-issues: 0 exclude-rules: From 4cd9017438e226ea7ec87304e9f3efcd4075d801 Mon Sep 17 00:00:00 2001 From: Francisco Serrano Date: Thu, 6 Feb 2025 17:55:45 +0200 Subject: [PATCH 12/16] chore: fix to linter errors --- upcloud/service/token_test.go | 2 +- upcloud/service/utils_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/upcloud/service/token_test.go b/upcloud/service/token_test.go index eb402be..4c281f8 100644 --- a/upcloud/service/token_test.go +++ b/upcloud/service/token_test.go @@ -103,7 +103,7 @@ func TestClientWithToken(t *testing.T) { require.NotEmpty(t, server, "Failed to get servers. This points to a problem with token auth") require.NoError(t, err, "Error getting the servers. This points to a problem with token auth") - //Delete the token + // Delete the token err = svcWithToken.DeleteToken(ctx, &request.DeleteTokenRequest{ID: token.ID}) require.NoError(t, err, "Token deletion should not fail") diff --git a/upcloud/service/utils_test.go b/upcloud/service/utils_test.go index 1119900..a390463 100644 --- a/upcloud/service/utils_test.go +++ b/upcloud/service/utils_test.go @@ -119,7 +119,7 @@ func record(t *testing.T, fixture string, f func(context.Context, *testing.T, *r require.NoError(t, err) }() - //Read token credentials from the environment, if it does not exists try to read user and password + // Read token credentials from the environment, if it does not exists try to read user and password var user, password string token := getTokenCredentials() if token == "" { From c53348346090958677fad56931d8a0b459a2db28 Mon Sep 17 00:00:00 2001 From: Francisco Serrano <59340762+paketeserrano@users.noreply.github.com> Date: Wed, 12 Feb 2025 10:32:29 +0200 Subject: [PATCH 13/16] Update CHANGELOG.md Co-authored-by: Toni Kangas --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 854910b..5002016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ See updating [Changelog example here](https://keepachangelog.com/en/1.0.0/) ## [8.15.0] ### Added -- Added support for token-based authentication in the client, including functions for token management. +- Experimental support for token-based authentication in the client and functions for token management. - managed load balancer: support for redirect rule HTTP status ## [8.14.0] From fdd124f514bbcb746fbfd33be31b580077bec1b6 Mon Sep 17 00:00:00 2001 From: Francisco Serrano Date: Wed, 12 Feb 2025 14:03:43 +0200 Subject: [PATCH 14/16] chore: updating the changelog message --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5002016..cdc4bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,9 @@ All notable changes to this project will be documented in this file. See updating [Changelog example here](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +- Experimental support for token-based authentication in the client and functions for token management. ## [8.15.0] - -### Added -- Experimental support for token-based authentication in the client and functions for token management. - managed load balancer: support for redirect rule HTTP status ## [8.14.0] From aaf0ba9b38a3c0e3c49073352267dd17c7131557 Mon Sep 17 00:00:00 2001 From: Francisco Serrano <59340762+paketeserrano@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:29:32 +0200 Subject: [PATCH 15/16] Update CHANGELOG.md Co-authored-by: Toni Kangas --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc4bb6..f8596df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. See updating [Changelog example here](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] + +### Added - Experimental support for token-based authentication in the client and functions for token management. ## [8.15.0] From 92d9b57b6b576128b0c410e59b7df4f2a9519a2e Mon Sep 17 00:00:00 2001 From: Francisco Serrano Date: Wed, 12 Feb 2025 17:18:39 +0200 Subject: [PATCH 16/16] One more change to the changelog.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8596df..7ffb546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ See updating [Changelog example here](https://keepachangelog.com/en/1.0.0/) - Experimental support for token-based authentication in the client and functions for token management. ## [8.15.0] + +### Added - managed load balancer: support for redirect rule HTTP status ## [8.14.0]