add ssh-key command

This commit is contained in:
edualb 2020-09-18 18:27:27 -03:00 committed by Mislav Marohnić
parent b5366c6ebf
commit e26a1b98a1
8 changed files with 490 additions and 22 deletions

View file

@ -203,9 +203,10 @@ func (c Client) HasMinimumScopes(hostname string) error {
}
search := map[string]bool{
"repo": false,
"read:org": false,
"admin:org": false,
"repo": false,
"read:org": false,
"admin:org": false,
"read:public_key": false,
}
for _, s := range strings.Split(scopesHeader, ",") {
search[strings.TrimSpace(s)] = true
@ -220,6 +221,10 @@ func (c Client) HasMinimumScopes(hostname string) error {
missingScopes = append(missingScopes, "read:org")
}
if !search["read:public_key"] && !search["admin:public_key"] {
missingScopes = append(missingScopes, "read:public_key")
}
if len(missingScopes) > 0 {
return &MissingScopesError{MissingScopes: missingScopes}
}

View file

@ -65,7 +65,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
}
minimumScopes := []string{"repo", "read:org", "gist", "workflow"}
minimumScopes := []string{"repo", "read:org", "gist", "workflow", "read:public_key"}
scopes := append(minimumScopes, additionalScopes...)
callbackURI := "http://127.0.0.1/callback"

View file

@ -210,7 +210,7 @@ func Test_loginRun_nontty(t *testing.T) {
Token: "abc123",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
},
wantHosts: "albert.wesker:\n oauth_token: abc123\n",
},
@ -221,7 +221,7 @@ func Test_loginRun_nontty(t *testing.T) {
Token: "abc456",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org,read:public_key"))
},
wantErr: `could not validate token: missing required scope 'repo'`,
},
@ -243,7 +243,7 @@ func Test_loginRun_nontty(t *testing.T) {
Token: "abc456",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org,read:public_key"))
},
wantHosts: "github.com:\n oauth_token: abc456\n",
},
@ -274,7 +274,7 @@ func Test_loginRun_nontty(t *testing.T) {
if tt.httpStubs != nil {
tt.httpStubs(reg)
} else {
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
}
mainBuf := bytes.Buffer{}
@ -315,7 +315,7 @@ func Test_loginRun_Survey(t *testing.T) {
_ = cfg.Set("github.com", "oauth_token", "ghi789")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
@ -341,7 +341,7 @@ func Test_loginRun_Survey(t *testing.T) {
as.StubOne(false) // cache credentials
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
@ -363,7 +363,7 @@ func Test_loginRun_Survey(t *testing.T) {
as.StubOne(false) // cache credentials
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
@ -436,7 +436,7 @@ func Test_loginRun_Survey(t *testing.T) {
if tt.httpStubs != nil {
tt.httpStubs(reg)
} else {
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))

View file

@ -91,7 +91,7 @@ func Test_statusRun(t *testing.T) {
_ = c.Set("github.com", "oauth_token", "abc123")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@ -106,8 +106,8 @@ func Test_statusRun(t *testing.T) {
_ = c.Set("github.com", "oauth_token", "abc123")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@ -124,7 +124,7 @@ func Test_statusRun(t *testing.T) {
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@ -140,8 +140,8 @@ func Test_statusRun(t *testing.T) {
_ = c.Set("github.com", "oauth_token", "abc123")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@ -159,8 +159,8 @@ func Test_statusRun(t *testing.T) {
_ = c.Set("github.com", "oauth_token", "xyz456")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@ -180,8 +180,8 @@ func Test_statusRun(t *testing.T) {
_ = c.Set("github.com", "oauth_token", "xyz456")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))

View file

@ -20,6 +20,7 @@ import (
repoCmd "github.com/cli/cli/pkg/cmd/repo"
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
secretCmd "github.com/cli/cli/pkg/cmd/secret"
sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key"
versionCmd "github.com/cli/cli/pkg/cmd/version"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
@ -76,6 +77,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(gistCmd.NewCmdGist(f))
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
cmd.AddCommand(secretCmd.NewCmdSecret(f))
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
// the `api` command should not inherit any extra HTTP headers
bareHTTPCmdFactory := *f

View file

@ -0,0 +1,152 @@
package list
import (
"bytes"
"errors"
"fmt"
"net/http"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/utils"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
// ListOptions struct for list command
type ListOptions struct {
HTTPClient func() (*http.Client, error)
IO *iostreams.IOStreams
Config func() (config.Config, error)
ListMsg []string
}
// NewCmdList creates a command for list all SSH Keys
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
HTTPClient: f.HttpClient,
IO: f.IOStreams,
Config: f.Config,
ListMsg: []string{},
}
cmd := &cobra.Command{
Use: "list",
Args: cobra.ExactArgs(0),
Short: "Lists currently added ssh keys",
Long: heredoc.Doc(`Lists currently added ssh keys.
This interactive command lists all SSH keys associated with your account
`),
Example: heredoc.Doc(`
$ gh ssh-key list
# => lists all ssh keys associated with your account
`),
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
return cmd
}
func listRun(opts *ListOptions) error {
apiClient, err := opts.getAPIClient()
if err != nil {
opts.printTerminal()
return err
}
err = opts.hasMinimumScopes(apiClient)
if err != nil {
opts.printTerminal()
return err
}
type keys struct {
Title string
Key string
}
type result []keys
rs := result{}
body := bytes.NewBufferString("")
err = apiClient.REST(ghinstance.Default(), "GET", "user/keys", body, &rs)
if err != nil {
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: Got %s", utils.RedX(), err))
opts.printTerminal()
return err
}
for _, r := range rs {
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s %s: %s \n %s: %s", utils.Cyan("✹"), utils.Bold("Name"), r.Title, utils.Bold("SSH-KEY"), r.Key))
}
opts.printTerminal()
return nil
}
func (opts *ListOptions) getAPIClient() (*api.Client, error) {
httpClient, err := opts.HTTPClient()
if err != nil {
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err))
return nil, err
}
return api.NewClientFromHTTP(httpClient), nil
}
func (opts *ListOptions) hasMinimumScopes(apiClient *api.Client) error {
cfg, err := opts.Config()
if err != nil {
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err))
return err
}
hostname := ghinstance.Default()
_, tokenSource, _ := cfg.GetWithSource(hostname, "oauth_token")
// TODO: Implement tests for this case when CheckWriteable function checks filesystem permissions
tokenIsWriteable := cfg.CheckWriteable(hostname, "oauth_token") == nil
err = apiClient.HasMinimumScopes(hostname)
if err != nil {
var missingScopes *api.MissingScopesError
if errors.As(err, &missingScopes) {
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: %s", utils.RedX(), err))
if tokenIsWriteable {
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To request missing scopes, run: %s %s", utils.Bold("gh auth refresh -h"), hostname))
}
} else {
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("%s: authentication failed", utils.RedX()))
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- The %s token in %s is no longer valid.", utils.Bold(hostname), utils.Bold(tokenSource)))
if tokenIsWriteable {
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To re-authenticate, run: %s %s", utils.Bold("gh auth login -h"), utils.Bold(hostname)))
opts.ListMsg = append(opts.ListMsg, fmt.Sprintf("- To forget about this host, run: %s %s", utils.Bold("gh auth logout -h"), utils.Bold(hostname)))
}
}
return err
}
return nil
}
func (opts *ListOptions) printTerminal() {
stderr := opts.IO.ErrOut
for _, line := range opts.ListMsg {
fmt.Fprintf(stderr, " %s\n", line)
}
}

View file

@ -0,0 +1,289 @@
package list
import (
"bytes"
"errors"
"net/http"
"reflect"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
)
func TestCmdList(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStdinTTY(true)
io.SetStderrTTY(true)
httpFunc := func() (*http.Client, error) { return nil, nil }
configFunc := func() (config.Config, error) { return nil, nil }
type input struct {
cli string
httpClient func() (*http.Client, error)
io *iostreams.IOStreams
config func() (config.Config, error)
}
tests := []struct {
name string
input input
wants ListOptions
}{
{
name: "no arguments",
input: input{
cli: "",
httpClient: httpFunc,
io: io,
config: configFunc,
},
wants: ListOptions{
HTTPClient: httpFunc,
Config: configFunc,
IO: io,
ListMsg: []string{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{
HttpClient: tt.input.httpClient,
Config: tt.input.config,
IOStreams: tt.input.io,
}
argv, err := shlex.Split(tt.input.cli)
if err != nil {
t.Errorf(`Split() = got %v`, err)
}
var gotOpts *ListOptions
cmd := NewCmdList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if err != nil {
t.Errorf(`ExecuteC() = got %v`, err)
}
if reflect.ValueOf(tt.wants.HTTPClient).Pointer() != reflect.ValueOf(gotOpts.HTTPClient).Pointer() {
t.Errorf(`HTTPClient has wrong values`)
}
if reflect.ValueOf(tt.wants.Config).Pointer() != reflect.ValueOf(gotOpts.Config).Pointer() {
t.Errorf(`Config has wrong values`)
}
if reflect.ValueOf(tt.wants.IO).Pointer() != reflect.ValueOf(gotOpts.IO).Pointer() {
t.Errorf(`IO has wrong values`)
}
if !reflect.DeepEqual(tt.wants.ListMsg, gotOpts.ListMsg) {
t.Errorf(`ListMsg has wrong values: want %v, got %v`, tt.wants.ListMsg, gotOpts.ListMsg)
}
})
}
}
func TestListRun(t *testing.T) {
type input struct {
httpStubs func(*httpmock.Registry)
configError bool
httpClientError bool
hasOauthToken bool
wantErr bool
}
tests := []struct {
name string
input input
want []string
}{
{
name: "name and corresponding ssh key",
input: input{
func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`),
)
reg.Register(
httpmock.REST("GET", ""),
httpmock.ScopesResponder("repo,read:org,read:public_key"),
)
},
false,
false,
true,
false,
},
want: []string{"✹ Name: Mac \n SSH-KEY: ssh-rsa AAAABbBB123"},
},
{
name: "config error",
input: input{
func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StringResponse(""),
)
reg.Register(
httpmock.REST("GET", ""),
httpmock.ScopesResponder("repo,read:org,read:public_key"),
)
},
true,
false,
true,
true,
},
want: []string{"X: Config error"},
},
{
name: "http client error",
input: input{
func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StringResponse(""),
)
reg.Register(
httpmock.REST("GET", ""),
httpmock.ScopesResponder("repo,read:org,read:public_key"),
)
},
false,
true,
true,
true,
},
want: []string{"X: HttpClient error"},
},
{
name: "not found on api.github.com/user/keys",
input: input{
func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StatusStringResponse(http.StatusNotFound, `{"message": "Not Found", "documentation_url": "url"}`),
)
reg.Register(
httpmock.REST("GET", ""),
httpmock.ScopesResponder("repo,read:org,read:public_key"),
)
},
false,
false,
true,
true,
},
want: []string{"X: Got HTTP 404 (https://api.github.com/user/keys)"},
},
{
name: "missing scope",
input: input{
func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`),
)
reg.Register(
httpmock.REST("GET", ""),
httpmock.ScopesResponder(""),
)
},
false,
false,
true,
true,
},
want: []string{
"X: missing required scope 'repo';missing required scope 'read:org';missing required scope 'read:public_key'",
"- To request missing scopes, run: gh auth refresh -h github.com",
},
},
{
name: "authentication failed",
input: input{
func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StringResponse(`[{"id":1234,"key":"ssh-rsa AAAABbBB123","title":"Mac"}]`),
)
reg.Register(
httpmock.REST("GET", ""),
httpmock.StatusStringResponse(http.StatusNotFound, `{"message": "Not Found", "documentation_url": "url"}`),
)
},
false,
false,
true,
true,
},
want: []string{
"X: authentication failed",
"- The github.com token in ~/.config/gh/hosts.yml is no longer valid.",
"- To re-authenticate, run: gh auth login -h github.com",
"- To forget about this host, run: gh auth logout -h github.com",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
tt.input.httpStubs(reg)
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStdinTTY(true)
io.SetStderrTTY(true)
opts := ListOptions{
HTTPClient: func() (*http.Client, error) {
if tt.input.httpClientError {
return nil, errors.New("HttpClient error")
}
return &http.Client{Transport: reg}, nil
},
IO: io,
Config: func() (config.Config, error) {
if tt.input.configError {
return nil, errors.New("Config error")
}
cfg := config.NewBlankConfig()
if tt.input.hasOauthToken {
err := cfg.Set("github.com", "oauth_token", "abc123")
if err != nil {
return nil, err
}
}
return cfg, nil
},
}
err := listRun(&opts)
if err != nil && !tt.input.wantErr {
t.Errorf("linRun() return error: %v", err)
}
if !reflect.DeepEqual(opts.ListMsg, tt.want) {
t.Errorf("linRun() = want %v, got %v", tt.want, opts.ListMsg)
}
})
}
}

View file

@ -0,0 +1,20 @@
package key
import (
cmdList "github.com/cli/cli/pkg/cmd/ssh-key/list"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
// NewCmdSSHKey creates a command for manage SSH Keys
func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "ssh-key <command>",
Short: "Manage SSH keys",
Long: "Work with GitHub SSH keys",
}
cmd.AddCommand(cmdList.NewCmdList(f, nil))
return cmd
}