Skip to content

Commit

Permalink
feat(cli): command token revoke to revoke personal access tokens (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
mikhailNumerous authored Sep 25, 2024
1 parent c446507 commit 0272afb
Show file tree
Hide file tree
Showing 8 changed files with 213 additions and 0 deletions.
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/revoke"

"github.com/spf13/cobra"
)
Expand All @@ -17,4 +18,5 @@ var Cmd = &cobra.Command{

func init() {
Cmd.AddCommand(create.Cmd)
Cmd.AddCommand(revoke.Cmd)
}
24 changes: 24 additions & 0 deletions cmd/token/revoke/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package revoke

import (
"github.com/spf13/cobra"
"numerous.com/cli/cmd/errorhandling"
"numerous.com/cli/internal/gql"
"numerous.com/cli/internal/token"
)

var id string

var Cmd = &cobra.Command{
Use: "revoke",
Short: "Revoke a personal access token.",
RunE: func(cmd *cobra.Command, args []string) error {
err := Revoke(cmd.Context(), token.NewService(gql.NewClient()), id)
return errorhandling.ErrorAlreadyPrinted(err)
},
}

func init() {
flags := Cmd.Flags()
flags.StringVarP(&id, "id", "", "", "The id of the personal access token.")
}
17 changes: 17 additions & 0 deletions cmd/token/revoke/mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package revoke

import (
"context"

"github.com/stretchr/testify/mock"
"numerous.com/cli/internal/token"
)

var _ TokenRevoker = &MockTokenRevoker{}

type MockTokenRevoker struct{ mock.Mock }

func (m *MockTokenRevoker) Revoke(ctx context.Context, id string) (token.RevokeTokenOutput, error) {
args := m.Called(ctx, id)
return args.Get(0).(token.RevokeTokenOutput), args.Error(1)
}
32 changes: 32 additions & 0 deletions cmd/token/revoke/revoke.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package revoke

import (
"context"
"errors"

"numerous.com/cli/cmd/output"
"numerous.com/cli/internal/token"
)

var ErrMissingTokenID = errors.New("missing token id argument")

type TokenRevoker interface {
Revoke(ctx context.Context, id string) (token.RevokeTokenOutput, error)
}

func Revoke(ctx context.Context, revoker TokenRevoker, id string) error {
if id == "" {
output.PrintError("Missing token id argument.", "")
return ErrMissingTokenID
}

out, err := revoker.Revoke(ctx, id)

if err == nil {
output.PrintlnOK("Revoked personal access token %q", out.Name)
} else {
output.PrintUnknownError(err)
}

return err
}
44 changes: 44 additions & 0 deletions cmd/token/revoke/revoke_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package revoke

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"numerous.com/cli/internal/token"
)

func TestRevoke(t *testing.T) {
id := "test-token-id"
name := "token name"
description := "token description"
testErr := errors.New("test error")

t.Run("revokes and returns expected name and description", func(t *testing.T) {
revoker := MockTokenRevoker{}
revoker.On("Revoke", mock.Anything, id).Return(token.RevokeTokenOutput{Name: name, Description: description}, nil)

err := Revoke(context.TODO(), &revoker, id)

assert.NoError(t, err)
revoker.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) {
revoker := MockTokenRevoker{}
revoker.On("Revoke", mock.Anything, id).Return(token.RevokeTokenOutput{}, expectedError)

err := Revoke(context.TODO(), &revoker, id)

assert.ErrorIs(t, err, expectedError)
})
}
})
}
33 changes: 33 additions & 0 deletions internal/token/token_revoke.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package token

import (
"context"

"github.com/hasura/go-graphql-client"
)

type RevokeTokenOutput struct {
Name string
Description string
}

type personalAccessTokenRevokeResponse struct {
PersonalAccessTokenRevoke struct {
Name string
Description string
} `graphql:"personalAccessTokenRevoke(id: $id)"`
}

func (s *Service) Revoke(ctx context.Context, id string) (RevokeTokenOutput, error) {
var resp personalAccessTokenRevokeResponse

if err := s.client.Mutate(ctx, &resp, map[string]interface{}{"id": graphql.ID(id)}); err != nil {
return RevokeTokenOutput{}, ConvertErrors(err)
} else {
result := resp.PersonalAccessTokenRevoke
return RevokeTokenOutput{
Name: result.Name,
Description: result.Description,
}, nil
}
}
57 changes: 57 additions & 0 deletions internal/token/token_revoke_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package token

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"numerous.com/cli/internal/test"
)

func TestRevoke(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": ["personalAccessTokenRevoke"]
}]
}`
resp := test.JSONResponse(respBody)
doer.On("Do", mock.Anything).Return(resp, nil)

actual, err := s.Revoke(context.TODO(), "some-token-id")

assert.Empty(t, actual)
assert.ErrorIs(t, err, ErrAccessDenied)
})

t.Run("returns expected revoked token", func(t *testing.T) {
doer := test.MockDoer{}
c := test.CreateTestGQLClient(t, &doer)
s := NewService(c)
respBody := `
{
"data": {
"personalAccessTokenRevoke": {
"name": "token name",
"description": "token description"
}
}
}`
resp := test.JSONResponse(respBody)
doer.On("Do", mock.Anything).Return(resp, nil)

actual, err := s.Revoke(context.TODO(), "some-token-id")

require.NoError(t, err)
expected := RevokeTokenOutput{Name: "token name", Description: "token description"}
assert.Equal(t, expected, actual)
})
}
4 changes: 4 additions & 0 deletions shared/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ enum AuthRole {
### User management
###############################################################################

directive @ownsPersonalAccessToken on FIELD_DEFINITION

enum Role {
ADMIN
USER
Expand Down Expand Up @@ -93,6 +95,8 @@ type Mutation {
personalAccessTokenCreate(
input: PersonalAccessTokenCreateInput!
): PersonalAccessTokenCreateResult! @hasRole(role: AUTHENTICATED)
personalAccessTokenRevoke(id: ID!): PersonalAccessTokenEntry!
@ownsPersonalAccessToken
}

###############################################################################
Expand Down

0 comments on commit 0272afb

Please sign in to comment.