diff --git a/cmd/output/error.go b/cmd/output/error.go index c122f261..cd47fe3d 100644 --- a/cmd/output/error.go +++ b/cmd/output/error.go @@ -84,3 +84,7 @@ func PrintManifestTOMLError(err error) { fmt.Println("There is a an error in your \"numerous.toml\" manifest.\n" + err.Error()) } + +func PrintErrorAccessDenied() { + PrintError("Access denied", "Your login may have expired. Try to log out and log back in again.") +} diff --git a/cmd/root.go b/cmd/root.go index 147a8dbd..8cb9b3c4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -87,6 +87,8 @@ func commandRequiresAuthentication(invokedCommandName string) bool { "numerous download", "numerous logs", "numerous token create", + "numerous token list", + "numerous token revoke", } for _, cmd := range commandsWithAuthRequired { diff --git a/cmd/token/cmd.go b/cmd/token/cmd.go index 07bf0179..efadfe73 100644 --- a/cmd/token/cmd.go +++ b/cmd/token/cmd.go @@ -4,6 +4,7 @@ import ( "numerous.com/cli/cmd/args" "numerous.com/cli/cmd/group" "numerous.com/cli/cmd/token/create" + "numerous.com/cli/cmd/token/list" "numerous.com/cli/cmd/token/revoke" "github.com/spf13/cobra" @@ -18,5 +19,6 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(create.Cmd) + Cmd.AddCommand(list.Cmd) Cmd.AddCommand(revoke.Cmd) } diff --git a/cmd/token/create/create.go b/cmd/token/create/create.go index 210d55b4..b3dde087 100644 --- a/cmd/token/create/create.go +++ b/cmd/token/create/create.go @@ -36,7 +36,7 @@ func Create(ctx context.Context, creator TokenCreator, input CreateInput) error switch { case errors.Is(err, token.ErrAccessDenied): - output.PrintError("Access denied", "Your login may have expired. Try to log out and log back in again.") + output.PrintErrorAccessDenied() case errors.Is(err, token.ErrPersonalAccessTokenAlreadyExists): output.PrintError("Error: %s", "", err.Error()) case errors.Is(err, token.ErrPersonalAccessTokenNameInvalid): diff --git a/cmd/token/list/cmd.go b/cmd/token/list/cmd.go new file mode 100644 index 00000000..c91cd1be --- /dev/null +++ b/cmd/token/list/cmd.go @@ -0,0 +1,17 @@ +package list + +import ( + "github.com/spf13/cobra" + "numerous.com/cli/cmd/errorhandling" + "numerous.com/cli/internal/gql" + "numerous.com/cli/internal/token" +) + +var Cmd = &cobra.Command{ + Use: "list", + Short: "List personal access tokens.", + RunE: func(cmd *cobra.Command, args []string) error { + err := List(cmd.Context(), token.NewService(gql.NewClient())) + return errorhandling.ErrorAlreadyPrinted(err) + }, +} diff --git a/cmd/token/list/list.go b/cmd/token/list/list.go new file mode 100644 index 00000000..f172f6cf --- /dev/null +++ b/cmd/token/list/list.go @@ -0,0 +1,51 @@ +package list + +import ( + "context" + "errors" + "time" + + "numerous.com/cli/cmd/output" + "numerous.com/cli/internal/token" +) + +type TokenLister interface { + List(ctx context.Context) (token.ListTokenOutput, error) +} + +func List(ctx context.Context, lister TokenLister) error { + out, err := lister.List(ctx) + + switch { + case err == nil: + list(out) + case errors.Is(err, token.ErrAccessDenied): + output.PrintErrorAccessDenied() + default: + output.PrintUnknownError(err) + } + + return err +} + +func list(tokens token.ListTokenOutput) { + for i, token := range tokens { + if i > 0 { + println() + } + + printToken(token) + } +} + +func printToken(token token.TokenEntry) { + expirationValue := "never" + if token.ExpiresAt != nil { + expirationValue = token.ExpiresAt.Format(time.RFC3339) + } + + println("Name: " + token.Name) + println("UUID: " + token.ID) + println("Description: " + token.Description) + println("Expires at: " + expirationValue) +} diff --git a/cmd/token/list/list_test.go b/cmd/token/list/list_test.go new file mode 100644 index 00000000..6ade48bb --- /dev/null +++ b/cmd/token/list/list_test.go @@ -0,0 +1,60 @@ +package list + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "numerous.com/cli/internal/token" +) + +func TestList(t *testing.T) { + testErr := errors.New("test error") + + t.Run("lists with no errors on expected response", func(t *testing.T) { + expirationTime, err := time.Parse(time.RFC3339, "2026-09-27T11:12:13.123456Z") + require.NoError(t, err) + + out := token.ListTokenOutput{ + { + ID: "first-token-id", + Name: "First token name", + Description: "first token description", + ExpiresAt: &expirationTime, + }, + { + ID: "second-token-id", + Name: "Second token name", + Description: "second token description", + ExpiresAt: nil, + }, + } + lister := MockTokenLister{} + lister.On("List", mock.Anything).Return(out, nil) + + err = List(context.TODO(), &lister) + + assert.NoError(t, err) + lister.AssertExpectations(t) + }) + + t.Run("passes on error", func(t *testing.T) { + for _, expectedError := range []error{ + token.ErrAccessDenied, + testErr, + } { + t.Run(expectedError.Error(), func(t *testing.T) { + lister := MockTokenLister{} + lister.On("List", mock.Anything).Return(token.ListTokenOutput{}, expectedError) + + err := List(context.TODO(), &lister) + + assert.ErrorIs(t, err, expectedError) + }) + } + }) +} diff --git a/cmd/token/list/mock.go b/cmd/token/list/mock.go new file mode 100644 index 00000000..9c4d9409 --- /dev/null +++ b/cmd/token/list/mock.go @@ -0,0 +1,17 @@ +package list + +import ( + "context" + + "github.com/stretchr/testify/mock" + "numerous.com/cli/internal/token" +) + +var _ TokenLister = &MockTokenLister{} + +type MockTokenLister struct{ mock.Mock } + +func (m *MockTokenLister) List(ctx context.Context) (token.ListTokenOutput, error) { + args := m.Called(ctx) + return args.Get(0).(token.ListTokenOutput), args.Error(1) +} diff --git a/cmd/token/revoke/revoke.go b/cmd/token/revoke/revoke.go index 73a671f2..9fea0b61 100644 --- a/cmd/token/revoke/revoke.go +++ b/cmd/token/revoke/revoke.go @@ -22,9 +22,12 @@ func Revoke(ctx context.Context, revoker TokenRevoker, id string) error { out, err := revoker.Revoke(ctx, id) - if err == nil { + switch { + case err == nil: output.PrintlnOK("Revoked personal access token %q", out.Name) - } else { + case errors.Is(err, token.ErrAccessDenied): + output.PrintErrorAccessDenied() + default: output.PrintUnknownError(err) } diff --git a/cmd/token/revoke/revoke_test.go b/cmd/token/revoke/revoke_test.go index e1d6644b..727d8765 100644 --- a/cmd/token/revoke/revoke_test.go +++ b/cmd/token/revoke/revoke_test.go @@ -16,7 +16,7 @@ func TestRevoke(t *testing.T) { description := "token description" testErr := errors.New("test error") - t.Run("revokes and returns expected name and description", func(t *testing.T) { + t.Run("revokes with no errors on expected response", func(t *testing.T) { revoker := MockTokenRevoker{} revoker.On("Revoke", mock.Anything, id).Return(token.RevokeTokenOutput{Name: name, Description: description}, nil) diff --git a/internal/token/token_list.go b/internal/token/token_list.go new file mode 100644 index 00000000..7f993288 --- /dev/null +++ b/internal/token/token_list.go @@ -0,0 +1,46 @@ +package token + +import ( + "context" + "time" +) + +type TokenEntry struct { + ID string + Name string + Description string + ExpiresAt *time.Time +} + +type ListTokenOutput []TokenEntry + +type personalAccessTokenListResponse struct { + Me struct { + PersonalAccessTokens []struct { + ID string + Name string + Description string + ExpiresAt *time.Time + } + } +} + +func (s *Service) List(ctx context.Context) (ListTokenOutput, error) { + var resp personalAccessTokenListResponse + + if err := s.client.Query(ctx, &resp, map[string]interface{}{}); err != nil { + return ListTokenOutput{}, ConvertErrors(err) + } else { + result := make(ListTokenOutput, len(resp.Me.PersonalAccessTokens)) + for i, entry := range resp.Me.PersonalAccessTokens { + result[i] = TokenEntry{ + ID: entry.ID, + Name: entry.Name, + Description: entry.Description, + ExpiresAt: entry.ExpiresAt, + } + } + + return result, nil + } +} diff --git a/internal/token/token_list_test.go b/internal/token/token_list_test.go new file mode 100644 index 00000000..b66b3da3 --- /dev/null +++ b/internal/token/token_list_test.go @@ -0,0 +1,85 @@ +package token + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "numerous.com/cli/internal/test" +) + +func TestList(t *testing.T) { + t.Run("given access denied then it returns access denied error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := NewService(c) + respBody := ` + { + "errors": [{ + "message": "access denied", + "location": [{"line": 1, "column": 1}], + "path": ["me"] + }] + }` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + actual, err := s.List(context.TODO()) + + assert.Empty(t, actual) + assert.ErrorIs(t, err, ErrAccessDenied) + }) + + t.Run("returns expected tokens", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := NewService(c) + respBody := ` + { + "data": { + "me": { + "personalAccessTokens": [ + { + "id": "first-token-id", + "name": "First token name", + "description": "first token description", + "expiresAt": "2026-09-27T11:12:13.123456Z" + }, + { + "id": "second-token-id", + "name": "Second token name", + "description": "second token description", + "expiresAt": null + } + ] + } + } + }` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + actual, err := s.List(context.TODO()) + + require.NoError(t, err) + expectedTime, err := time.Parse(time.RFC3339, "2026-09-27T11:12:13.123456Z") + require.NoError(t, err) + expected := ListTokenOutput{ + { + ID: "first-token-id", + Name: "First token name", + Description: "first token description", + ExpiresAt: &expectedTime, + }, + { + ID: "second-token-id", + Name: "Second token name", + Description: "second token description", + ExpiresAt: nil, + }, + } + assert.Equal(t, expected, actual) + }) +} diff --git a/shared/schema.gql b/shared/schema.gql index 359abd32..75ebd5de 100644 --- a/shared/schema.gql +++ b/shared/schema.gql @@ -49,6 +49,7 @@ type User { fullName: String! memberships: [OrganizationMembership!]! @isMe email: String! + personalAccessTokens: [PersonalAccessTokenEntry!]! @isMe } type UserNotFound {