Skip to content

Commit

Permalink
feat(cli): command token list to list personal access tokens (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
mikhailNumerous authored Sep 30, 2024
1 parent f23497b commit 305e989
Show file tree
Hide file tree
Showing 13 changed files with 292 additions and 4 deletions.
4 changes: 4 additions & 0 deletions cmd/output/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions cmd/token/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -18,5 +19,6 @@ var Cmd = &cobra.Command{

func init() {
Cmd.AddCommand(create.Cmd)
Cmd.AddCommand(list.Cmd)
Cmd.AddCommand(revoke.Cmd)
}
2 changes: 1 addition & 1 deletion cmd/token/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
17 changes: 17 additions & 0 deletions cmd/token/list/cmd.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
51 changes: 51 additions & 0 deletions cmd/token/list/list.go
Original file line number Diff line number Diff line change
@@ -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)
}
60 changes: 60 additions & 0 deletions cmd/token/list/list_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
})
}
17 changes: 17 additions & 0 deletions cmd/token/list/mock.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 5 additions & 2 deletions cmd/token/revoke/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/token/revoke/revoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
46 changes: 46 additions & 0 deletions internal/token/token_list.go
Original file line number Diff line number Diff line change
@@ -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
}
}
85 changes: 85 additions & 0 deletions internal/token/token_list_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
1 change: 1 addition & 0 deletions shared/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type User {
fullName: String!
memberships: [OrganizationMembership!]! @isMe
email: String!
personalAccessTokens: [PersonalAccessTokenEntry!]! @isMe
}

type UserNotFound {
Expand Down

0 comments on commit 305e989

Please sign in to comment.