From 7b08d7248e90da04f5080ec327ff6e9fae63794d Mon Sep 17 00:00:00 2001 From: riku salkia Date: Thu, 9 Jan 2025 12:15:30 +0200 Subject: [PATCH 01/11] feat: API token operations --- internal/commands/all/all.go | 8 ++ internal/commands/executor.go | 5 ++ internal/commands/tokens/create.go | 109 ++++++++++++++++++++++++ internal/commands/tokens/create_test.go | 105 +++++++++++++++++++++++ internal/commands/tokens/delete.go | 42 +++++++++ internal/commands/tokens/delete_test.go | 29 +++++++ internal/commands/tokens/list.go | 56 ++++++++++++ internal/commands/tokens/list_test.go | 69 +++++++++++++++ internal/commands/tokens/show.go | 53 ++++++++++++ internal/commands/tokens/show_test.go | 64 ++++++++++++++ internal/commands/tokens/tokens.go | 14 +++ internal/mock/mock.go | 30 +++++++ internal/service/service.go | 1 + internal/testutils/time.go | 19 +++++ 14 files changed, 604 insertions(+) create mode 100644 internal/commands/tokens/create.go create mode 100644 internal/commands/tokens/create_test.go create mode 100644 internal/commands/tokens/delete.go create mode 100644 internal/commands/tokens/delete_test.go create mode 100644 internal/commands/tokens/list.go create mode 100644 internal/commands/tokens/list_test.go create mode 100644 internal/commands/tokens/show.go create mode 100644 internal/commands/tokens/show_test.go create mode 100644 internal/commands/tokens/tokens.go create mode 100644 internal/testutils/time.go diff --git a/internal/commands/all/all.go b/internal/commands/all/all.go index 5515caa73..723c60ed3 100644 --- a/internal/commands/all/all.go +++ b/internal/commands/all/all.go @@ -28,6 +28,7 @@ import ( "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/servergroup" "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/storage" storagebackup "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/storage/backup" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/tokens" "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/zone" "github.com/UpCloudLtd/upcloud-cli/v3/internal/config" @@ -122,6 +123,13 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) { permissionsCommand := commands.BuildCommand(permissions.BasePermissionsCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(permissions.ListCommand(), permissionsCommand.Cobra(), conf) + // Token + tokenCommand := commands.BuildCommand(tokens.BaseTokensCommand(), rootCmd, conf) + commands.BuildCommand(tokens.CreateCommand(), tokenCommand.Cobra(), conf) + commands.BuildCommand(tokens.ListCommand(), tokenCommand.Cobra(), conf) + commands.BuildCommand(tokens.ShowCommand(), tokenCommand.Cobra(), conf) + commands.BuildCommand(tokens.DeleteCommand(), tokenCommand.Cobra(), conf) + // Zone zoneCommand := commands.BuildCommand(zone.BaseZoneCommand(), rootCmd, conf) commands.BuildCommand(zone.ListCommand(), zoneCommand.Cobra(), conf) diff --git a/internal/commands/executor.go b/internal/commands/executor.go index 7045ce5c7..924d971f5 100644 --- a/internal/commands/executor.go +++ b/internal/commands/executor.go @@ -33,6 +33,7 @@ type Executor interface { Firewall() service.Firewall IPAddress() service.IPAddress Account() service.Account + Token() service.Token All() internal.AllServices Debug(msg string, args ...interface{}) WithLogger(args ...interface{}) Executor @@ -135,6 +136,10 @@ func (e executorImpl) Account() service.Account { return e.service } +func (e executorImpl) Token() service.Token { + return e.service +} + func (e executorImpl) All() internal.AllServices { return e.service } diff --git a/internal/commands/tokens/create.go b/internal/commands/tokens/create.go new file mode 100644 index 000000000..001810ea5 --- /dev/null +++ b/internal/commands/tokens/create.go @@ -0,0 +1,109 @@ +package tokens + +import ( + "time" + + "fmt" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/ui" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + "github.com/spf13/pflag" +) + +// CreateCommand creates the "tokens create" command +func CreateCommand() commands.Command { + return &createCommand{ + BaseCommand: commands.New( + "create", + "Create an API token", + `upctl tokens create --name test --expires_in 1h`, + `upctl tokens create --name test --expires_in 1h --allowed-ip-ranges "0.0.0.0/0" --allowed-ip-ranges "::/0"`, + ), + } +} + +var defaultCreateParams = &createParams{ + CreateTokenRequest: request.CreateTokenRequest{}, + name: "", + expiresIn: 0, + allowedIPRanges: []string{}, // TODO: should we default to empty or "0.0.0.0/0", "::/0"? + canCreateTokens: false, +} + +func newCreateParams() createParams { + return createParams{ + CreateTokenRequest: request.CreateTokenRequest{}, + } +} + +type createParams struct { + request.CreateTokenRequest + name string + expiresIn time.Duration + // expiresAt time.Time/string // TODO: is it necessary to be able to define exact time for expiry instead of duration? + canCreateTokens bool + allowedIPRanges []string +} + +func (s *createParams) processParams() error { + s.ExpiresAt = time.Now().Add(s.expiresIn) + s.Name = s.name + s.CanCreateSubTokens = s.canCreateTokens + s.AllowedIPRanges = s.allowedIPRanges + return nil +} + +type createCommand struct { + *commands.BaseCommand + params createParams + flagSet *pflag.FlagSet +} + +func applyCreateFlags(fs *pflag.FlagSet, dst, def *createParams) { + fs.StringVar(&dst.name, "name", def.name, "Name for the token.") + fs.DurationVar(&dst.expiresIn, "expires_in", def.expiresIn, "Duration until the token expires.") + fs.BoolVar(&dst.canCreateTokens, "can-create-tokens", def.canCreateTokens, "Allow token to be used to create further tokens.") + fs.StringArrayVar(&dst.allowedIPRanges, "allowed-ip-ranges", def.allowedIPRanges, "Allowed IP ranges for the token.") +} + +// InitCommand implements Command.InitCommand +func (s *createCommand) InitCommand() { + s.flagSet = &pflag.FlagSet{} + s.params = newCreateParams() + applyCreateFlags(s.flagSet, &s.params, defaultCreateParams) + + s.AddFlags(s.flagSet) + _ = s.Cobra().MarkFlagRequired("name") + _ = s.Cobra().MarkFlagRequired("expires_in") +} + +// ExecuteWithoutArguments implements commands.NoArgumentCommand +func (s *createCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) { + svc := exec.Token() + + if err := s.params.processParams(); err != nil { + return nil, err + } + + msg := fmt.Sprintf("Creating token %s", s.params.Name) + exec.PushProgressStarted(msg) + + res, err := svc.CreateToken(exec.Context(), &s.params.CreateTokenRequest) + if err != nil { + return commands.HandleError(exec, msg, err) + } + + exec.PushProgressSuccess(msg) + + return output.MarshaledWithHumanDetails{Value: res, Details: []output.DetailRow{ + {Title: "API Token", Value: res.APIToken, Colour: ui.DefaultNoteColours}, + {Title: "Name", Value: res.Name}, + {Title: "ID", Value: res.ID, Colour: ui.DefaultUUUIDColours}, + {Title: "Type", Value: res.Type}, + {Title: "Created At", Value: res.CreatedAt.Format(time.RFC3339)}, + {Title: "Expires At", Value: res.ExpiresAt.Format(time.RFC3339)}, + {Title: "Allowed IP Ranges", Value: res.AllowedIPRanges}, + {Title: "Can Create Sub Tokens", Value: res.CanCreateSubTokens}, + }}, nil +} diff --git a/internal/commands/tokens/create_test.go b/internal/commands/tokens/create_test.go new file mode 100644 index 000000000..ff90484db --- /dev/null +++ b/internal/commands/tokens/create_test.go @@ -0,0 +1,105 @@ +package tokens + +import ( + "testing" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/config" + smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/mockexecute" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "time" +) + +func TestCreateToken(t *testing.T) { + created := time.Now() + + for _, test := range []struct { + name string + resp *upcloud.Token + args []string + req request.CreateTokenRequest + errFn assert.ErrorAssertionFunc + }{ + { + name: "defaults", + args: []string{ + "--name", "test", + "--expires_in", "1h", + }, + req: request.CreateTokenRequest{ + Name: "test", + ExpiresAt: created.Add(1 * time.Hour), + CanCreateSubTokens: false, + AllowedIPRanges: nil, + }, + resp: &upcloud.Token{ + APIToken: "ucat_01JH5D3ZZJVZS6JC713FA11CB8", + ID: "0cd8eab4-ecb7-445b-a457-6019b0a00496", + Name: "test", + Type: "workspace", + CreatedAt: created, + ExpiresAt: created.Add(1 * time.Hour), + LastUsed: nil, + CanCreateSubTokens: false, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + }, + errFn: assert.NoError, + }, + { + name: "invalid expiry", + args: []string{ + "--name", "test", + "--expires_in", "seppo", + }, + errFn: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.ErrorContains(t, err, `time: invalid duration "seppo"`) + }, + }, + { + name: "missing name", + args: []string{ + "--expires_in", "1h", + }, + errFn: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.ErrorContains(t, err, `required flag(s) "name" not set`) + }, + }, + { + name: "missing expiry", + args: []string{ + "--name", "test", + }, + errFn: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.ErrorContains(t, err, `required flag(s) "expires_in" not set`) + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + conf := config.New() + testCmd := CreateCommand() + mService := new(smock.Service) + + if test.resp != nil { + mService.On("CreateToken", mock.MatchedBy(func(req *request.CreateTokenRequest) bool { + // service uses time.Now() with "expires_in" added to it to set ExpiresAt, so we can't set a mock to any + // static value. Instead, we'll just check that the request has the correct name and that the ExpiresAt + // is within 1 second of "now". + return assert.Equal(t, test.req.Name, req.Name) && assert.InDelta(t, test.req.ExpiresAt.UnixMilli(), req.ExpiresAt.UnixMilli(), 1000) + })).Once().Return(test.resp, nil) + } + + c := commands.BuildCommand(testCmd, nil, conf) + c.Cobra().SetArgs(test.args) + _, err := mockexecute.MockExecute(c, mService, conf) + + if test.errFn(t, err) { + mService.AssertExpectations(t) + } + }) + } +} diff --git a/internal/commands/tokens/delete.go b/internal/commands/tokens/delete.go new file mode 100644 index 000000000..acf97928a --- /dev/null +++ b/internal/commands/tokens/delete.go @@ -0,0 +1,42 @@ +package tokens + +import ( + "fmt" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" +) + +// DeleteCommand creates the "tokens delete" command +func DeleteCommand() commands.Command { + return &deleteCommand{ + BaseCommand: commands.New( + "delete", + "Delete an API token", + "upctl tokens delete 0c0e2abf-cd89-490b-abdb-d06db6e8d816", + ), + } +} + +type deleteCommand struct { + *commands.BaseCommand +} + +// Execute implements commands.MultipleArgumentCommand +func (c *deleteCommand) Execute(exec commands.Executor, arg string) (output.Output, error) { + svc := exec.Token() + msg := fmt.Sprintf("Deleting API token %v", arg) + exec.PushProgressStarted(msg) + + err := svc.DeleteToken(exec.Context(), &request.DeleteTokenRequest{ + ID: arg, + }) + if err != nil { + return commands.HandleError(exec, msg, err) + } + + exec.PushProgressSuccess(msg) + + return output.None{}, nil +} diff --git a/internal/commands/tokens/delete_test.go b/internal/commands/tokens/delete_test.go new file mode 100644 index 000000000..87053fce3 --- /dev/null +++ b/internal/commands/tokens/delete_test.go @@ -0,0 +1,29 @@ +package tokens + +import ( + "testing" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/config" + smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + "github.com/gemalto/flume" + "github.com/stretchr/testify/assert" +) + +func TestDeleteCommand(t *testing.T) { + tokenID := "0cdabbf9-090b-4fc5-a6ae-3f76801ed171" + + svc := &smock.Service{} + conf := config.New() + + svc.On("DeleteToken", + &request.DeleteTokenRequest{ID: tokenID}, + ).Once().Return(nil) + + command := commands.BuildCommand(DeleteCommand(), nil, conf) + _, err := command.(commands.MultipleArgumentCommand).Execute(commands.NewExecutor(conf, svc, flume.New("test")), tokenID) + assert.NoError(t, err) + + svc.AssertExpectations(t) +} diff --git a/internal/commands/tokens/list.go b/internal/commands/tokens/list.go new file mode 100644 index 000000000..9d39fc909 --- /dev/null +++ b/internal/commands/tokens/list.go @@ -0,0 +1,56 @@ +package tokens + +import ( + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/ui" +) + +// ListCommand creates the 'tokens list' command +func ListCommand() commands.Command { + return &listCommand{ + BaseCommand: commands.New("list", "List API tokens", "upctl tokens list"), + } +} + +type listCommand struct { + *commands.BaseCommand +} + +func (l *listCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) { + svc := exec.All() + tokens, err := svc.GetTokens(exec.Context()) + if err != nil { + return nil, err + } + + var rows []output.TableRow + for _, token := range *tokens { + rows = append(rows, output.TableRow{ + token.Name, + token.ID, + token.Type, + token.CreatedAt, + token.LastUsed, + token.ExpiresAt, + token.AllowedIPRanges, + token.CanCreateSubTokens, + }) + } + return output.MarshaledWithHumanOutput{ + Value: tokens, + Output: output.Table{ + Columns: []output.TableColumn{ + {Key: "name", Header: "Name"}, + {Key: "id", Header: "Token ID", Colour: ui.DefaultUUUIDColours}, + {Key: "type", Header: "Type"}, + {Key: "created_at", Header: "Created At"}, + {Key: "last_used", Header: "Last Used"}, + {Key: "expires_at", Header: "Expires At"}, + {Key: "allowed_ip_ranges", Header: "Allowed IP Ranges"}, + {Key: "can_create_sub_tokens", Header: "Can Create Sub Tokens"}, + }, + Rows: rows, + }, + }, nil +} diff --git a/internal/commands/tokens/list_test.go b/internal/commands/tokens/list_test.go new file mode 100644 index 000000000..8b4340e77 --- /dev/null +++ b/internal/commands/tokens/list_test.go @@ -0,0 +1,69 @@ +package tokens + +import ( + "bytes" + "testing" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/config" + smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/testutils" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" + "github.com/gemalto/flume" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/stretchr/testify/assert" +) + +func TestListCommand(t *testing.T) { + text.DisableColors() + + tokens := upcloud.Tokens{ + { + ID: "0cdabbf9-090b-4fc5-a6ae-3f76801ed171", + Name: "test", + Type: "workspace", + CreatedAt: *testutils.MustParseRFC3339(t, "2021-10-01T12:00:00Z"), + ExpiresAt: *testutils.MustParseRFC3339(t, "2022-10-01T12:00:00Z"), + LastUsed: testutils.MustParseRFC3339(t, "2021-11-01T12:00:00Z"), + CanCreateSubTokens: false, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + }, + { + ID: "0cdabbf9-090b-4fc5-a6ae-3f76801ed172", + Name: "foo", + Type: "workspace", + CreatedAt: *testutils.MustParseRFC3339(t, "2021-10-01T12:00:00Z"), + ExpiresAt: *testutils.MustParseRFC3339(t, "2022-10-01T12:00:00Z"), + LastUsed: nil, + CanCreateSubTokens: true, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + }, + } + + expected := ` + Name Token ID Type Created At Last Used Expires At Allowed IP Ranges Can Create Sub Tokens +────── ────────────────────────────────────── ─────────── ─────────────────────────────── ─────────────────────────────── ─────────────────────────────── ─────────────────── ─────────────────────── + test 0cdabbf9-090b-4fc5-a6ae-3f76801ed171 workspace 2021-10-01 12:00:00 +0000 UTC 2021-11-01 12:00:00 +0000 UTC 2022-10-01 12:00:00 +0000 UTC [0.0.0.0/0 ::/0] false + foo 0cdabbf9-090b-4fc5-a6ae-3f76801ed172 workspace 2021-10-01 12:00:00 +0000 UTC 2022-10-01 12:00:00 +0000 UTC [0.0.0.0/0 ::/0] true + +` + + svc := &smock.Service{} + conf := config.New() + + svc.On("GetTokens").Once().Return(&tokens, nil) + conf.Viper().Set(config.KeyOutput, config.ValueOutputHuman) + + command := commands.BuildCommand(ListCommand(), nil, conf) + out, err := command.(commands.NoArgumentCommand).ExecuteWithoutArguments(commands.NewExecutor(conf, svc, flume.New("test"))) + assert.NoError(t, err) + + buf := bytes.NewBuffer(nil) + err = output.Render(buf, conf.Output(), out) + assert.NoError(t, err) + assert.Equal(t, expected, buf.String()) + + svc.AssertExpectations(t) +} diff --git a/internal/commands/tokens/show.go b/internal/commands/tokens/show.go new file mode 100644 index 000000000..d3306e055 --- /dev/null +++ b/internal/commands/tokens/show.go @@ -0,0 +1,53 @@ +package tokens + +import ( + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/ui" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" +) + +// ShowCommand creates the "tokens show" command +func ShowCommand() commands.Command { + return &showCommand{ + BaseCommand: commands.New( + "show", + "Show API token details", + "upctl tokens show 0cdabbf9-090b-4fc5-a6ae-3f76801ed171", + ), + } +} + +type showCommand struct { + *commands.BaseCommand +} + +// Execute implements commands.MultipleArgumentCommand +func (c *showCommand) Execute(exec commands.Executor, uuid string) (output.Output, error) { + svc := exec.Token() + token, err := svc.GetTokenDetails(exec.Context(), &request.GetTokenDetailsRequest{ID: uuid}) + if err != nil { + return nil, err + } + + details := output.Details{ + Sections: []output.DetailSection{ + { + Rows: []output.DetailRow{ + {Title: "Name:", Key: "name", Value: token.Name}, + {Title: "Token ID", Key: "id", Colour: ui.DefaultUUUIDColours, Value: token.ID}, + {Title: "Type", Key: "type", Value: token.Type}, + {Title: "Created At", Key: "created_at", Value: token.CreatedAt}, + {Title: "Last Used", Key: "last_used", Value: token.LastUsed}, + {Title: "Expires At", Key: "expires_at", Value: token.ExpiresAt}, + {Title: "Allowed IP Ranges", Key: "allowed_ip_ranges", Value: token.AllowedIPRanges}, + {Title: "Can Create Sub Tokens", Key: "can_create_sub_tokens", Value: token.CanCreateSubTokens}, + }, + }, + }, + } + return output.MarshaledWithHumanOutput{ + Value: token, + Output: details, + }, nil +} diff --git a/internal/commands/tokens/show_test.go b/internal/commands/tokens/show_test.go new file mode 100644 index 000000000..ac8bc2c60 --- /dev/null +++ b/internal/commands/tokens/show_test.go @@ -0,0 +1,64 @@ +package tokens + +import ( + "bytes" + "testing" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/config" + smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/testutils" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + "github.com/gemalto/flume" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/stretchr/testify/assert" +) + +func TestShowCommand(t *testing.T) { + text.DisableColors() + + token := upcloud.Token{ + ID: "0cdabbf9-090b-4fc5-a6ae-3f76801ed171", + Name: "test", + Type: "workspace", + CreatedAt: *testutils.MustParseRFC3339(t, "2021-10-01T12:00:00Z"), + ExpiresAt: *testutils.MustParseRFC3339(t, "2022-10-01T12:00:00Z"), + LastUsed: testutils.MustParseRFC3339(t, "2021-11-01T12:00:00Z"), + CanCreateSubTokens: false, + AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, + } + + expected := ` + Name: test + Token ID 0cdabbf9-090b-4fc5-a6ae-3f76801ed171 + Type workspace + Created At 2021-10-01 12:00:00 +0000 UTC + Last Used 2021-11-01 12:00:00 +0000 UTC + Expires At 2022-10-01 12:00:00 +0000 UTC + Allowed IP Ranges [0.0.0.0/0 ::/0] + Can Create Sub Tokens false + +` + + svc := &smock.Service{} + conf := config.New() + + svc.On("GetTokenDetails", + &request.GetTokenDetailsRequest{ID: "0cdabbf9-090b-4fc5-a6ae-3f76801ed171"}, + ).Once().Return(&token, nil) + conf.Viper().Set(config.KeyOutput, config.ValueOutputHuman) + + command := commands.BuildCommand(ShowCommand(), nil, conf) + out, err := command.(commands.MultipleArgumentCommand).Execute(commands.NewExecutor(conf, svc, flume.New("test")), token.ID) + assert.NoError(t, err) + + buf := bytes.NewBuffer(nil) + err = output.Render(buf, conf.Output(), out) + assert.NoError(t, err) + assert.Equal(t, expected, buf.String()) + + svc.AssertExpectations(t) +} diff --git a/internal/commands/tokens/tokens.go b/internal/commands/tokens/tokens.go new file mode 100644 index 000000000..abfaaae76 --- /dev/null +++ b/internal/commands/tokens/tokens.go @@ -0,0 +1,14 @@ +package tokens + +import ( + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" +) + +// BaseTokensCommand creates the base 'tokens' command +func BaseTokensCommand() commands.Command { + return &tokensCommand{commands.New("tokens", "Manage tokens")} +} + +type tokensCommand struct { + *commands.BaseCommand +} diff --git a/internal/mock/mock.go b/internal/mock/mock.go index 0e9fb6d5a..151ea7f9e 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -15,6 +15,35 @@ type Service struct { mock.Mock } +func (m *Service) CreateToken(_ context.Context, r *request.CreateTokenRequest) (*upcloud.Token, error) { + args := m.Called(r) + if args[0] == nil { + return nil, args.Error(1) + } + return args[0].(*upcloud.Token), args.Error(1) +} + +func (m *Service) GetTokenDetails(_ context.Context, r *request.GetTokenDetailsRequest) (*upcloud.Token, error) { + args := m.Called(r) + if args[0] == nil { + return nil, args.Error(1) + } + return args[0].(*upcloud.Token), args.Error(1) +} + +func (m *Service) DeleteToken(_ context.Context, r *request.DeleteTokenRequest) error { + args := m.Called(r) + return args.Error(0) +} + +func (m *Service) GetTokens(context.Context) (*upcloud.Tokens, error) { + args := m.Called() + if args[0] == nil { + return nil, args.Error(1) + } + return args[0].(*upcloud.Tokens), args.Error(1) +} + // GetAccount implements service.Account.GetAccount func (m *Service) GetAccount(context.Context) (*upcloud.Account, error) { args := m.Called() @@ -66,6 +95,7 @@ var ( _ service.ServerGroup = &Service{} _ service.ManagedObjectStorage = &Service{} _ service.Gateway = &Service{} + _ service.Token = &Service{} ) // GetServerConfigurations implements service.Server.GetServerConfigurations diff --git a/internal/service/service.go b/internal/service/service.go index 059bb7ef7..92b518fca 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -23,4 +23,5 @@ type AllServices interface { service.Gateway service.Host service.Partner + service.Token } diff --git a/internal/testutils/time.go b/internal/testutils/time.go new file mode 100644 index 000000000..417eacc96 --- /dev/null +++ b/internal/testutils/time.go @@ -0,0 +1,19 @@ +package testutils + +import ( + "github.com/stretchr/testify/require" + "testing" + + "time" +) + +func MustParseRFC3339(t *testing.T, timeStr string) *time.Time { + t.Helper() + + p, err := time.Parse(time.RFC3339, timeStr) + require.NoError(t, err) + + p = p.UTC() + + return &p +} From 25a87f9e9b1021030191a4a948929a5ad915d4de Mon Sep 17 00:00:00 2001 From: riku salkia Date: Thu, 9 Jan 2025 14:52:31 +0200 Subject: [PATCH 02/11] feat: paging support for Token API --- internal/commands/tokens/list.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/commands/tokens/list.go b/internal/commands/tokens/list.go index 9d39fc909..8ad9e3a22 100644 --- a/internal/commands/tokens/list.go +++ b/internal/commands/tokens/list.go @@ -3,7 +3,10 @@ package tokens import ( "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/paging" "github.com/UpCloudLtd/upcloud-cli/v3/internal/ui" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + ) // ListCommand creates the 'tokens list' command @@ -13,13 +16,22 @@ func ListCommand() commands.Command { } } +func (l *listCommand) InitCommand() { + fs := &pflag.FlagSet{} + l.ConfigureFlags(fs) + l.AddFlags(fs) +} + type listCommand struct { *commands.BaseCommand + paging.PageParameters } func (l *listCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) { svc := exec.All() - tokens, err := svc.GetTokens(exec.Context()) + tokens, err := svc.GetTokens(exec.Context(), &request.GetTokensRequest{ + Page: l.Page(), + }) if err != nil { return nil, err } From e2116730a8e82b2f7aeda871c39176fbd93140a2 Mon Sep 17 00:00:00 2001 From: riku salkia Date: Thu, 9 Jan 2025 16:22:38 +0200 Subject: [PATCH 03/11] fix: paging support for Token API --- internal/commands/tokens/list.go | 3 ++- internal/mock/mock.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/commands/tokens/list.go b/internal/commands/tokens/list.go index 8ad9e3a22..ce4bad2ab 100644 --- a/internal/commands/tokens/list.go +++ b/internal/commands/tokens/list.go @@ -6,7 +6,8 @@ import ( "github.com/UpCloudLtd/upcloud-cli/v3/internal/paging" "github.com/UpCloudLtd/upcloud-cli/v3/internal/ui" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" - + + "github.com/spf13/pflag" ) // ListCommand creates the 'tokens list' command diff --git a/internal/mock/mock.go b/internal/mock/mock.go index 151ea7f9e..4e0bba8c7 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -36,7 +36,7 @@ func (m *Service) DeleteToken(_ context.Context, r *request.DeleteTokenRequest) return args.Error(0) } -func (m *Service) GetTokens(context.Context) (*upcloud.Tokens, error) { +func (m *Service) GetTokens(context.Context, *request.GetTokensRequest) (*upcloud.Tokens, error) { args := m.Called() if args[0] == nil { return nil, args.Error(1) From b97d067f9d66ed9871ae0cae687e0e4b2fb237b8 Mon Sep 17 00:00:00 2001 From: riku salkia Date: Fri, 10 Jan 2025 10:00:52 +0200 Subject: [PATCH 04/11] feat: expires_at for token creation expires_in is useful for short lived tokens, but not so much for > 24h tokens as it only supports units up to an hour, e.g. 168h for one week. --- internal/commands/tokens/create.go | 23 +++++++++++++++++------ internal/commands/tokens/create_test.go | 22 ++++++++++++++++------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/internal/commands/tokens/create.go b/internal/commands/tokens/create.go index 001810ea5..43c739201 100644 --- a/internal/commands/tokens/create.go +++ b/internal/commands/tokens/create.go @@ -39,15 +39,26 @@ func newCreateParams() createParams { type createParams struct { request.CreateTokenRequest - name string - expiresIn time.Duration - // expiresAt time.Time/string // TODO: is it necessary to be able to define exact time for expiry instead of duration? + name string + expiresIn time.Duration + expiresAt string canCreateTokens bool allowedIPRanges []string } func (s *createParams) processParams() error { - s.ExpiresAt = time.Now().Add(s.expiresIn) + if s.expiresIn == 0 && s.expiresAt == "" { + return fmt.Errorf("either expires_in or expires_at must be set") + } + if s.expiresAt != "" { + var err error + s.ExpiresAt, err = time.Parse(time.RFC3339, s.expiresAt) + if err != nil { + return fmt.Errorf("invalid expires_at: %w", err) + } + } else { + s.ExpiresAt = time.Now().Add(s.expiresIn) + } s.Name = s.name s.CanCreateSubTokens = s.canCreateTokens s.AllowedIPRanges = s.allowedIPRanges @@ -62,7 +73,8 @@ type createCommand struct { func applyCreateFlags(fs *pflag.FlagSet, dst, def *createParams) { fs.StringVar(&dst.name, "name", def.name, "Name for the token.") - fs.DurationVar(&dst.expiresIn, "expires_in", def.expiresIn, "Duration until the token expires.") + fs.StringVar(&dst.expiresAt, "expires_at", def.expiresAt, "Exact time when the token expires in RFC3339 format. e.g. 2025-01-01T00:00:00Z") + fs.DurationVar(&dst.expiresIn, "expires_in", def.expiresIn, "Duration until the token expires. e.g. 24h") fs.BoolVar(&dst.canCreateTokens, "can-create-tokens", def.canCreateTokens, "Allow token to be used to create further tokens.") fs.StringArrayVar(&dst.allowedIPRanges, "allowed-ip-ranges", def.allowedIPRanges, "Allowed IP ranges for the token.") } @@ -75,7 +87,6 @@ func (s *createCommand) InitCommand() { s.AddFlags(s.flagSet) _ = s.Cobra().MarkFlagRequired("name") - _ = s.Cobra().MarkFlagRequired("expires_in") } // ExecuteWithoutArguments implements commands.NoArgumentCommand diff --git a/internal/commands/tokens/create_test.go b/internal/commands/tokens/create_test.go index ff90484db..894d436f8 100644 --- a/internal/commands/tokens/create_test.go +++ b/internal/commands/tokens/create_test.go @@ -51,22 +51,32 @@ func TestCreateToken(t *testing.T) { errFn: assert.NoError, }, { - name: "invalid expiry", + name: "missing name", + args: []string{ + "--expires_in", "1h", + }, + errFn: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.ErrorContains(t, err, `required flag(s) "name" not set`) + }, + }, + { + name: "invalid expires_in", args: []string{ "--name", "test", "--expires_in", "seppo", }, errFn: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.ErrorContains(t, err, `time: invalid duration "seppo"`) + return assert.ErrorContains(t, err, `invalid argument "seppo" for "--expires_in"`) }, }, { - name: "missing name", + name: "invalid expires_at", args: []string{ - "--expires_in", "1h", + "--name", "test", + "--expires_at", "seppo", }, errFn: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.ErrorContains(t, err, `required flag(s) "name" not set`) + return assert.ErrorContains(t, err, `invalid expires_at: `) }, }, { @@ -75,7 +85,7 @@ func TestCreateToken(t *testing.T) { "--name", "test", }, errFn: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.ErrorContains(t, err, `required flag(s) "expires_in" not set`) + return assert.ErrorContains(t, err, `either expires_in or expires_at must be set`) }, }, } { From b06b573bb33b7883fdcf32b9ce437aa15b612f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 28 Jan 2025 16:07:21 +0200 Subject: [PATCH 05/11] refactor: rename tokens command to token For consistency with other commands. --- internal/commands/tokens/create.go | 6 +++--- internal/commands/tokens/delete.go | 4 ++-- internal/commands/tokens/list.go | 4 ++-- internal/commands/tokens/show.go | 4 ++-- internal/commands/tokens/tokens.go | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/commands/tokens/create.go b/internal/commands/tokens/create.go index 43c739201..ca294a471 100644 --- a/internal/commands/tokens/create.go +++ b/internal/commands/tokens/create.go @@ -11,14 +11,14 @@ import ( "github.com/spf13/pflag" ) -// CreateCommand creates the "tokens create" command +// CreateCommand creates the "token create" command func CreateCommand() commands.Command { return &createCommand{ BaseCommand: commands.New( "create", "Create an API token", - `upctl tokens create --name test --expires_in 1h`, - `upctl tokens create --name test --expires_in 1h --allowed-ip-ranges "0.0.0.0/0" --allowed-ip-ranges "::/0"`, + `upctl token create --name test --expires_in 1h`, + `upctl token create --name test --expires_in 1h --allowed-ip-ranges "0.0.0.0/0" --allowed-ip-ranges "::/0"`, ), } } diff --git a/internal/commands/tokens/delete.go b/internal/commands/tokens/delete.go index acf97928a..473fa14b5 100644 --- a/internal/commands/tokens/delete.go +++ b/internal/commands/tokens/delete.go @@ -8,13 +8,13 @@ import ( "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" ) -// DeleteCommand creates the "tokens delete" command +// DeleteCommand creates the "token delete" command func DeleteCommand() commands.Command { return &deleteCommand{ BaseCommand: commands.New( "delete", "Delete an API token", - "upctl tokens delete 0c0e2abf-cd89-490b-abdb-d06db6e8d816", + "upctl token delete 0c0e2abf-cd89-490b-abdb-d06db6e8d816", ), } } diff --git a/internal/commands/tokens/list.go b/internal/commands/tokens/list.go index ce4bad2ab..42bede83c 100644 --- a/internal/commands/tokens/list.go +++ b/internal/commands/tokens/list.go @@ -10,10 +10,10 @@ import ( "github.com/spf13/pflag" ) -// ListCommand creates the 'tokens list' command +// ListCommand creates the 'token list' command func ListCommand() commands.Command { return &listCommand{ - BaseCommand: commands.New("list", "List API tokens", "upctl tokens list"), + BaseCommand: commands.New("list", "List API tokens", "upctl token list"), } } diff --git a/internal/commands/tokens/show.go b/internal/commands/tokens/show.go index d3306e055..88d8ea20a 100644 --- a/internal/commands/tokens/show.go +++ b/internal/commands/tokens/show.go @@ -7,13 +7,13 @@ import ( "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" ) -// ShowCommand creates the "tokens show" command +// ShowCommand creates the "token show" command func ShowCommand() commands.Command { return &showCommand{ BaseCommand: commands.New( "show", "Show API token details", - "upctl tokens show 0cdabbf9-090b-4fc5-a6ae-3f76801ed171", + "upctl token show 0cdabbf9-090b-4fc5-a6ae-3f76801ed171", ), } } diff --git a/internal/commands/tokens/tokens.go b/internal/commands/tokens/tokens.go index abfaaae76..a504ed92f 100644 --- a/internal/commands/tokens/tokens.go +++ b/internal/commands/tokens/tokens.go @@ -4,9 +4,9 @@ import ( "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" ) -// BaseTokensCommand creates the base 'tokens' command +// BaseTokensCommand creates the base 'token' command func BaseTokensCommand() commands.Command { - return &tokensCommand{commands.New("tokens", "Manage tokens")} + return &tokensCommand{commands.New("token", "Manage tokens")} } type tokensCommand struct { From 12f9f870e0fdbb3ff74f0aad23103e0d497fdeab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 28 Jan 2025 16:11:03 +0200 Subject: [PATCH 06/11] refactor(token): use dash in --expires-{at,in} For consistency with other options. --- internal/commands/tokens/create.go | 12 ++++++------ internal/commands/tokens/create_test.go | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/commands/tokens/create.go b/internal/commands/tokens/create.go index ca294a471..c77a88921 100644 --- a/internal/commands/tokens/create.go +++ b/internal/commands/tokens/create.go @@ -17,8 +17,8 @@ func CreateCommand() commands.Command { BaseCommand: commands.New( "create", "Create an API token", - `upctl token create --name test --expires_in 1h`, - `upctl token create --name test --expires_in 1h --allowed-ip-ranges "0.0.0.0/0" --allowed-ip-ranges "::/0"`, + `upctl token create --name test --expires-in 1h`, + `upctl token create --name test --expires-in 1h --allowed-ip-ranges "0.0.0.0/0" --allowed-ip-ranges "::/0"`, ), } } @@ -48,13 +48,13 @@ type createParams struct { func (s *createParams) processParams() error { if s.expiresIn == 0 && s.expiresAt == "" { - return fmt.Errorf("either expires_in or expires_at must be set") + return fmt.Errorf("either expires-in or expires-at must be set") } if s.expiresAt != "" { var err error s.ExpiresAt, err = time.Parse(time.RFC3339, s.expiresAt) if err != nil { - return fmt.Errorf("invalid expires_at: %w", err) + return fmt.Errorf("invalid expires-at: %w", err) } } else { s.ExpiresAt = time.Now().Add(s.expiresIn) @@ -73,8 +73,8 @@ type createCommand struct { func applyCreateFlags(fs *pflag.FlagSet, dst, def *createParams) { fs.StringVar(&dst.name, "name", def.name, "Name for the token.") - fs.StringVar(&dst.expiresAt, "expires_at", def.expiresAt, "Exact time when the token expires in RFC3339 format. e.g. 2025-01-01T00:00:00Z") - fs.DurationVar(&dst.expiresIn, "expires_in", def.expiresIn, "Duration until the token expires. e.g. 24h") + fs.StringVar(&dst.expiresAt, "expires-at", def.expiresAt, "Exact time when the token expires in RFC3339 format. e.g. 2025-01-01T00:00:00Z") + fs.DurationVar(&dst.expiresIn, "expires-in", def.expiresIn, "Duration until the token expires. e.g. 24h") fs.BoolVar(&dst.canCreateTokens, "can-create-tokens", def.canCreateTokens, "Allow token to be used to create further tokens.") fs.StringArrayVar(&dst.allowedIPRanges, "allowed-ip-ranges", def.allowedIPRanges, "Allowed IP ranges for the token.") } diff --git a/internal/commands/tokens/create_test.go b/internal/commands/tokens/create_test.go index 894d436f8..5ef2c0c4b 100644 --- a/internal/commands/tokens/create_test.go +++ b/internal/commands/tokens/create_test.go @@ -29,7 +29,7 @@ func TestCreateToken(t *testing.T) { name: "defaults", args: []string{ "--name", "test", - "--expires_in", "1h", + "--expires-in", "1h", }, req: request.CreateTokenRequest{ Name: "test", @@ -53,30 +53,30 @@ func TestCreateToken(t *testing.T) { { name: "missing name", args: []string{ - "--expires_in", "1h", + "--expires-in", "1h", }, errFn: func(t assert.TestingT, err error, i ...interface{}) bool { return assert.ErrorContains(t, err, `required flag(s) "name" not set`) }, }, { - name: "invalid expires_in", + name: "invalid expires-in", args: []string{ "--name", "test", - "--expires_in", "seppo", + "--expires-in", "seppo", }, errFn: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.ErrorContains(t, err, `invalid argument "seppo" for "--expires_in"`) + return assert.ErrorContains(t, err, `invalid argument "seppo" for "--expires-in"`) }, }, { - name: "invalid expires_at", + name: "invalid expires-at", args: []string{ "--name", "test", - "--expires_at", "seppo", + "--expires-at", "seppo", }, errFn: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.ErrorContains(t, err, `invalid expires_at: `) + return assert.ErrorContains(t, err, `invalid expires-at: `) }, }, { @@ -85,7 +85,7 @@ func TestCreateToken(t *testing.T) { "--name", "test", }, errFn: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.ErrorContains(t, err, `either expires_in or expires_at must be set`) + return assert.ErrorContains(t, err, `either expires-in or expires-at must be set`) }, }, } { @@ -96,7 +96,7 @@ func TestCreateToken(t *testing.T) { if test.resp != nil { mService.On("CreateToken", mock.MatchedBy(func(req *request.CreateTokenRequest) bool { - // service uses time.Now() with "expires_in" added to it to set ExpiresAt, so we can't set a mock to any + // service uses time.Now() with "expires-in" added to it to set ExpiresAt, so we can't set a mock to any // static value. Instead, we'll just check that the request has the correct name and that the ExpiresAt // is within 1 second of "now". return assert.Equal(t, test.req.Name, req.Name) && assert.InDelta(t, test.req.ExpiresAt.UnixMilli(), req.ExpiresAt.UnixMilli(), 1000) From 38b2f5cf5ee8cca286a417eb7976293bedcd3622 Mon Sep 17 00:00:00 2001 From: Toni Kangas Date: Thu, 30 Jan 2025 19:42:48 +0200 Subject: [PATCH 07/11] chore: update Go SDK version --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 344feddab..b922b6152 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22 require ( github.com/UpCloudLtd/progress v1.0.2 - github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.0 + github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.1-0.20250130171841-f32ab8d1a73c github.com/adrg/xdg v0.3.2 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/gemalto/flume v0.12.0 diff --git a/go.sum b/go.sum index 4afdf7d41..7690de0f9 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/UpCloudLtd/progress v1.0.2 h1:CTr1bBuFuXop9TEhR1PakbUMPTlUVL7Bgae9JgqXwPg= github.com/UpCloudLtd/progress v1.0.2/go.mod h1:iGxOnb9HvHW0yrLGUjHr0lxHhn7TehgWwh7a8NqK6iQ= -github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.0 h1:bJozr/MtrSl4P3ynq4Nkr8kGPQfPAGpGJ7/S/iVI1cc= -github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.0/go.mod h1:bFnrOkfsDDmsb94nnBV5eSQjjsfDnwAzLnCt9+b4t/4= +github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.1-0.20250130171841-f32ab8d1a73c h1:X88TqqpSTbJno235xQ8lKyj+6nLeaHVLYw85nRXh/AM= +github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.1-0.20250130171841-f32ab8d1a73c/go.mod h1:bFnrOkfsDDmsb94nnBV5eSQjjsfDnwAzLnCt9+b4t/4= github.com/adrg/xdg v0.3.2 h1:GUSGQ5pHdev83AYhDSS1A/CX+0JIsxbiWtow2DSA+RU= github.com/adrg/xdg v0.3.2/go.mod h1:7I2hH/IT30IsupOpKZ5ue7/qNi3CoKzD6tL3HwpaRMQ= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= From 08f11e5cf167fd1844f1ceae67067c167a6e1332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 31 Jan 2025 09:21:02 +0200 Subject: [PATCH 08/11] chore: address linting issues --- internal/commands/tokens/create.go | 2 +- internal/commands/tokens/create_test.go | 10 +++++----- internal/commands/tokens/delete_test.go | 9 +++------ internal/testutils/time.go | 4 ++-- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/internal/commands/tokens/create.go b/internal/commands/tokens/create.go index c77a88921..3a62adc2a 100644 --- a/internal/commands/tokens/create.go +++ b/internal/commands/tokens/create.go @@ -1,9 +1,9 @@ package tokens import ( + "fmt" "time" - "fmt" "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" "github.com/UpCloudLtd/upcloud-cli/v3/internal/ui" diff --git a/internal/commands/tokens/create_test.go b/internal/commands/tokens/create_test.go index 5ef2c0c4b..fce413d21 100644 --- a/internal/commands/tokens/create_test.go +++ b/internal/commands/tokens/create_test.go @@ -2,6 +2,7 @@ package tokens import ( "testing" + "time" "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" "github.com/UpCloudLtd/upcloud-cli/v3/internal/config" @@ -12,7 +13,6 @@ import ( "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "time" ) func TestCreateToken(t *testing.T) { @@ -55,7 +55,7 @@ func TestCreateToken(t *testing.T) { args: []string{ "--expires-in", "1h", }, - errFn: func(t assert.TestingT, err error, i ...interface{}) bool { + errFn: func(t assert.TestingT, err error, _ ...interface{}) bool { return assert.ErrorContains(t, err, `required flag(s) "name" not set`) }, }, @@ -65,7 +65,7 @@ func TestCreateToken(t *testing.T) { "--name", "test", "--expires-in", "seppo", }, - errFn: func(t assert.TestingT, err error, i ...interface{}) bool { + errFn: func(t assert.TestingT, err error, _ ...interface{}) bool { return assert.ErrorContains(t, err, `invalid argument "seppo" for "--expires-in"`) }, }, @@ -75,7 +75,7 @@ func TestCreateToken(t *testing.T) { "--name", "test", "--expires-at", "seppo", }, - errFn: func(t assert.TestingT, err error, i ...interface{}) bool { + errFn: func(t assert.TestingT, err error, _ ...interface{}) bool { return assert.ErrorContains(t, err, `invalid expires-at: `) }, }, @@ -84,7 +84,7 @@ func TestCreateToken(t *testing.T) { args: []string{ "--name", "test", }, - errFn: func(t assert.TestingT, err error, i ...interface{}) bool { + errFn: func(t assert.TestingT, err error, _ ...interface{}) bool { return assert.ErrorContains(t, err, `either expires-in or expires-at must be set`) }, }, diff --git a/internal/commands/tokens/delete_test.go b/internal/commands/tokens/delete_test.go index 87053fce3..4f503f76e 100644 --- a/internal/commands/tokens/delete_test.go +++ b/internal/commands/tokens/delete_test.go @@ -12,17 +12,14 @@ import ( ) func TestDeleteCommand(t *testing.T) { - tokenID := "0cdabbf9-090b-4fc5-a6ae-3f76801ed171" - svc := &smock.Service{} conf := config.New() + dr := &request.DeleteTokenRequest{ID: "0cdabbf9-090b-4fc5-a6ae-3f76801ed171"} - svc.On("DeleteToken", - &request.DeleteTokenRequest{ID: tokenID}, - ).Once().Return(nil) + svc.On("DeleteToken", dr).Once().Return(nil) command := commands.BuildCommand(DeleteCommand(), nil, conf) - _, err := command.(commands.MultipleArgumentCommand).Execute(commands.NewExecutor(conf, svc, flume.New("test")), tokenID) + _, err := command.(commands.MultipleArgumentCommand).Execute(commands.NewExecutor(conf, svc, flume.New("test")), dr.ID) assert.NoError(t, err) svc.AssertExpectations(t) diff --git a/internal/testutils/time.go b/internal/testutils/time.go index 417eacc96..5eb8f81cc 100644 --- a/internal/testutils/time.go +++ b/internal/testutils/time.go @@ -1,10 +1,10 @@ package testutils import ( - "github.com/stretchr/testify/require" "testing" - "time" + + "github.com/stretchr/testify/require" ) func MustParseRFC3339(t *testing.T, timeStr string) *time.Time { From 1c18808a57cf16bbb8b7a59496325bed2df1f9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 31 Jan 2025 09:39:30 +0200 Subject: [PATCH 09/11] feat(token): completion improvements --- internal/commands/tokens/create.go | 2 +- internal/commands/tokens/delete.go | 4 +++ internal/commands/tokens/show.go | 4 +++ internal/completion/token.go | 28 +++++++++++++++ internal/completion/token_test.go | 57 ++++++++++++++++++++++++++++++ internal/resolver/token.go | 34 ++++++++++++++++++ 6 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 internal/completion/token.go create mode 100644 internal/completion/token_test.go create mode 100644 internal/resolver/token.go diff --git a/internal/commands/tokens/create.go b/internal/commands/tokens/create.go index 3a62adc2a..e2c66f76e 100644 --- a/internal/commands/tokens/create.go +++ b/internal/commands/tokens/create.go @@ -86,7 +86,7 @@ func (s *createCommand) InitCommand() { applyCreateFlags(s.flagSet, &s.params, defaultCreateParams) s.AddFlags(s.flagSet) - _ = s.Cobra().MarkFlagRequired("name") + commands.Must(s.Cobra().MarkFlagRequired("name")) } // ExecuteWithoutArguments implements commands.NoArgumentCommand diff --git a/internal/commands/tokens/delete.go b/internal/commands/tokens/delete.go index 473fa14b5..0b796f07e 100644 --- a/internal/commands/tokens/delete.go +++ b/internal/commands/tokens/delete.go @@ -2,6 +2,8 @@ package tokens import ( "fmt" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/completion" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/resolver" "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" @@ -21,6 +23,8 @@ func DeleteCommand() commands.Command { type deleteCommand struct { *commands.BaseCommand + resolver.CachingToken + completion.Token } // Execute implements commands.MultipleArgumentCommand diff --git a/internal/commands/tokens/show.go b/internal/commands/tokens/show.go index 88d8ea20a..da672ace0 100644 --- a/internal/commands/tokens/show.go +++ b/internal/commands/tokens/show.go @@ -2,7 +2,9 @@ package tokens import ( "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/completion" "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/resolver" "github.com/UpCloudLtd/upcloud-cli/v3/internal/ui" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" ) @@ -20,6 +22,8 @@ func ShowCommand() commands.Command { type showCommand struct { *commands.BaseCommand + resolver.CachingToken + completion.Token } // Execute implements commands.MultipleArgumentCommand diff --git a/internal/completion/token.go b/internal/completion/token.go new file mode 100644 index 000000000..3c9a899c5 --- /dev/null +++ b/internal/completion/token.go @@ -0,0 +1,28 @@ +package completion + +import ( + "context" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/service" + "github.com/spf13/cobra" +) + +// Token implements argument completion for tokens, by id. +type Token struct{} + +// make sure Token implements the interface +var _ Provider = Token{} + +// CompleteArgument implements completion.Provider +func (s Token) CompleteArgument(ctx context.Context, svc service.AllServices, toComplete string) ([]string, cobra.ShellCompDirective) { + tokens, err := svc.GetTokens(ctx, &request.GetTokensRequest{}) + if err != nil { + return None(toComplete) + } + var vals []string + for _, t := range *tokens { + vals = append(vals, t.ID) + } + return MatchStringPrefix(vals, toComplete, true), cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/completion/token_test.go b/internal/completion/token_test.go new file mode 100644 index 000000000..3407ddebc --- /dev/null +++ b/internal/completion/token_test.go @@ -0,0 +1,57 @@ +package completion_test + +import ( + "context" + "fmt" + "testing" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/completion" + smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var mockTokens = &upcloud.Tokens{ + {Name: "mock1", ID: "0c1eadbe-efde-adbe-efde-adbeefdeadbe"}, + {Name: "mock2", ID: "0c2eadbe-efde-adbe-efde-adbeefdeadbe"}, + {Name: "bock1", ID: "0c3eadbe-efde-adbe-efde-adbeefdeadbe"}, + {Name: "bock2", ID: "0c4eadbe-efde-adbe-efde-adbeefdeadbe"}, + {Name: "dock1", ID: "0c5eadbe-efde-adbe-efde-adbeefdeadbe"}, +} + +func TestToken_CompleteArgument(t *testing.T) { + for _, test := range []struct { + name string + complete string + expectedMatches []string + expectedDirective cobra.ShellCompDirective + }{ + {name: "basic id", complete: "0c2", expectedMatches: []string{"0c2eadbe-efde-adbe-efde-adbeefdeadbe"}, expectedDirective: cobra.ShellCompDirectiveNoFileComp}, + {name: "multiple ids", complete: "0c", expectedMatches: []string{ + "0c1eadbe-efde-adbe-efde-adbeefdeadbe", + "0c2eadbe-efde-adbe-efde-adbeefdeadbe", + "0c3eadbe-efde-adbe-efde-adbeefdeadbe", + "0c4eadbe-efde-adbe-efde-adbeefdeadbe", + "0c5eadbe-efde-adbe-efde-adbeefdeadbe", + }, expectedDirective: cobra.ShellCompDirectiveNoFileComp}, + } { + t.Run(test.name, func(t *testing.T) { + mService := new(smock.Service) + mService.On("GetTokens", mock.Anything).Return(mockTokens, nil) + tokens, directive := completion.Token{}.CompleteArgument(context.TODO(), mService, test.complete) + assert.Equal(t, test.expectedMatches, tokens) + assert.Equal(t, test.expectedDirective, directive) + }) + } +} + +func TestToken_CompleteArgumentServiceFail(t *testing.T) { + mService := new(smock.Service) + mService.On("GetTokens", mock.Anything).Return(nil, fmt.Errorf("MOCKFAIL")) + tokens, directive := completion.Token{}.CompleteArgument(context.TODO(), mService, "FOO") + assert.Nil(t, tokens) + assert.Equal(t, cobra.ShellCompDirectiveDefault, directive) +} diff --git a/internal/resolver/token.go b/internal/resolver/token.go new file mode 100644 index 000000000..997eb8e07 --- /dev/null +++ b/internal/resolver/token.go @@ -0,0 +1,34 @@ +package resolver + +import ( + "context" + + internal "github.com/UpCloudLtd/upcloud-cli/v3/internal/service" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" +) + +// CachingToken implements resolver for tokens, caching the results. +type CachingToken struct{} + +// make sure we implement the ResolutionProvider interface +var _ ResolutionProvider = CachingToken{} + +// Get implements ResolutionProvider.Get +func (s CachingToken) Get(ctx context.Context, svc internal.AllServices) (Resolver, error) { + tokens, err := svc.GetTokens(ctx, &request.GetTokensRequest{}) + if err != nil { + return nil, err + } + return func(arg string) Resolved { + rv := Resolved{Arg: arg} + for _, token := range *tokens { + rv.AddMatch(token.ID, MatchArgWithWhitespace(arg, token.ID)) + } + return rv + }, nil +} + +// PositionalArgumentHelp implements resolver.ResolutionProvider +func (s CachingToken) PositionalArgumentHelp() string { + return "" +} From 4e16e7a68bf7fd14cefee09aef7fd7aa2aed12bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 31 Jan 2025 10:13:03 +0200 Subject: [PATCH 10/11] chore: address linting issues --- internal/commands/tokens/delete.go | 1 + internal/completion/token.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/commands/tokens/delete.go b/internal/commands/tokens/delete.go index 0b796f07e..042059df2 100644 --- a/internal/commands/tokens/delete.go +++ b/internal/commands/tokens/delete.go @@ -2,6 +2,7 @@ package tokens import ( "fmt" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/completion" "github.com/UpCloudLtd/upcloud-cli/v3/internal/resolver" diff --git a/internal/completion/token.go b/internal/completion/token.go index 3c9a899c5..6d5ca8355 100644 --- a/internal/completion/token.go +++ b/internal/completion/token.go @@ -2,6 +2,7 @@ package completion import ( "context" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" "github.com/UpCloudLtd/upcloud-cli/v3/internal/service" From e06fc89febfd9d18455993e926b9d67f07004db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 4 Feb 2025 13:16:35 +0200 Subject: [PATCH 11/11] fix(token): prevent create flag filename completions --- internal/commands/tokens/create.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/commands/tokens/create.go b/internal/commands/tokens/create.go index e2c66f76e..3f2ff23dd 100644 --- a/internal/commands/tokens/create.go +++ b/internal/commands/tokens/create.go @@ -77,6 +77,11 @@ func applyCreateFlags(fs *pflag.FlagSet, dst, def *createParams) { fs.DurationVar(&dst.expiresIn, "expires-in", def.expiresIn, "Duration until the token expires. e.g. 24h") fs.BoolVar(&dst.canCreateTokens, "can-create-tokens", def.canCreateTokens, "Allow token to be used to create further tokens.") fs.StringArrayVar(&dst.allowedIPRanges, "allowed-ip-ranges", def.allowedIPRanges, "Allowed IP ranges for the token.") + + commands.Must(fs.SetAnnotation("name", commands.FlagAnnotationNoFileCompletions, nil)) + commands.Must(fs.SetAnnotation("expires-at", commands.FlagAnnotationNoFileCompletions, nil)) + commands.Must(fs.SetAnnotation("expires-in", commands.FlagAnnotationNoFileCompletions, nil)) + commands.Must(fs.SetAnnotation("allowed-ip-ranges", commands.FlagAnnotationNoFileCompletions, nil)) } // InitCommand implements Command.InitCommand