diff --git a/.golangci.yaml b/.golangci.yaml index 657f2702..42e3eb80 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index c460e704..7ffb546c 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 +- Experimental support for token-based authentication in the client and functions for token management. + ## [8.15.0] ### Added diff --git a/examples/upcloud-cli/main.go b/examples/upcloud-cli/main.go index 5e0a9225..17c305bf 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 098298c9..05228684 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 (EXPERIMENTAL) 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, diff --git a/upcloud/request/token.go b/upcloud/request/token.go new file mode 100644 index 00000000..eee1845c --- /dev/null +++ b/upcloud/request/token.go @@ -0,0 +1,57 @@ +package request + +import ( + "fmt" + "time" +) + +const basePath = "/account/tokens" + +// 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 (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) + f = append(f, r.Page) + return fmt.Sprintf("%s?%s", basePath, encodeQueryFilters(f)) + } + + return basePath +} + +// CreateTokenRequest (EXPERIMENTAL) 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 (EXPERIMENTAL) implements the Request interface. +func (r *CreateTokenRequest) RequestURL() string { + return basePath +} + +// DeleteTokenRequest (EXPERIMENTAL) represents a request to delete a token. +type DeleteTokenRequest struct { + ID string +} + +// RequestURL (EXPERIMENTAL) implements the Request interface. +func (r *DeleteTokenRequest) RequestURL() string { + return fmt.Sprintf("%s/%s", basePath, r.ID) +} diff --git a/upcloud/request/token_test.go b/upcloud/request/token_test.go new file mode 100644 index 00000000..1c71c729 --- /dev/null +++ b/upcloud/request/token_test.go @@ -0,0 +1,49 @@ +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()) + 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) { + 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()) +} diff --git a/upcloud/service/fixtures/token.yaml b/upcloud/service/fixtures/token.yaml new file mode 100644 index 00000000..ed9475bd --- /dev/null +++ b/upcloud/service/fixtures/token.yaml @@ -0,0 +1,167 @@ +--- +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 + Authorization: + - Basic [REDACTED] + 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: '{"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: + - "291" + Content-Type: + - application/json + Date: + - Thu, 06 Feb 2025 15:33:07 GMT + Strict-Transport-Security: + - max-age=63072000 + 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 + Authorization: + - Basic [REDACTED] + 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: '{"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: + - Thu, 06 Feb 2025 15:33:07 GMT + Strict-Transport-Security: + - max-age=63072000 + status: 201 Created + code: 201 + duration: "" +- request: + body: "" + form: {} + 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/0c511c65-5375-4dd3-8a76-df12ba772110 + method: GET + response: + 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: + - "249" + Content-Type: + - application/json + Date: + - Thu, 06 Feb 2025 15:33:08 GMT + Strict-Transport-Security: + - max-age=63072000 + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + 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 + method: GET + response: + 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: + - "1093" + Content-Type: + - application/json + Date: + - Thu, 06 Feb 2025 15:33:08 GMT + Strict-Transport-Security: + - max-age=63072000 + 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 + Authorization: + - Basic [REDACTED] + 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: '{"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: + - "292" + Content-Type: + - application/json + Date: + - Thu, 06 Feb 2025 15:33:09 GMT + Strict-Transport-Security: + - max-age=63072000 + status: 201 Created + code: 201 + duration: "" +- request: + body: "" + form: {} + 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/0ce0d95e-a0ac-4b99-af2c-392da20ef103 + method: DELETE + response: + body: "" + headers: + Date: + - 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.go b/upcloud/service/token.go new file mode 100644 index 00000000..014c88b4 --- /dev/null +++ b/upcloud/service/token.go @@ -0,0 +1,38 @@ +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, *request.GetTokensRequest) (*upcloud.Tokens, error) + DeleteToken(context.Context, *request.DeleteTokenRequest) error +} + +// 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 (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 (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 (EXPERIMENTAL) 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 00000000..4c281f89 --- /dev/null +++ b/upcloud/service/token_test.go @@ -0,0 +1,122 @@ +package service + +import ( + "context" + "os" + "strings" + "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" + + "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 + tokens, err := svc.GetTokens(ctx, &request.GetTokensRequest{}) + 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})) + }) +} + +// 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", + ExpiresAt: expires, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + CanCreateSubTokens: true, + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // Get the initial token + 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, "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 9571f00c..a3904636 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. diff --git a/upcloud/token.go b/upcloud/token.go new file mode 100644 index 00000000..018f1dbc --- /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"` + 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 new file mode 100644 index 00000000..7713d10d --- /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, + 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"}, + }, + { + ID: "deadbeef-dead-beef-dead-beefdeadbee2", + Name: "token_pat", + Type: TokenTypePAT, + 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"}, + }, + } + 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, + 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"}, + }) + assert.NoError(t, err) + assert.JSONEq(t, want, string(got)) +}