diff --git a/pkg/cmd/ssh-key/delete/delete.go b/pkg/cmd/ssh-key/delete/delete.go new file mode 100644 index 00000000000..a11cbd76928 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/delete.go @@ -0,0 +1,87 @@ +package delete + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + HttpClient func() (*http.Client, error) + + KeyID string + Confirmed bool + Prompter prompter.Prompter +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + HttpClient: f.HttpClient, + Config: f.Config, + IO: f.IOStreams, + Prompter: f.Prompter, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an SSH key from your GitHub account", + Args: cmdutil.ExactArgs(1, "cannot delete: key id required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.KeyID = args[0] + + if !opts.IO.CanPrompt() && !opts.Confirmed { + return cmdutil.FlagErrorf("--confirm required when not running interactively") + } + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Confirmed, "confirm", "y", false, "Skip the confirmation prompt") + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + host, _ := cfg.DefaultHost() + key, err := getSSHKey(httpClient, host, opts.KeyID) + if err != nil { + return err + } + + if !opts.Confirmed { + if err := opts.Prompter.ConfirmDeletion(key.Title); err != nil { + return err + } + } + + err = deleteSSHKey(httpClient, host, opts.KeyID) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s SSH key %q (%s) deleted from your account\n", cs.SuccessIcon(), key.Title, opts.KeyID) + } + return nil +} diff --git a/pkg/cmd/ssh-key/delete/delete_test.go b/pkg/cmd/ssh-key/delete/delete_test.go new file mode 100644 index 00000000000..437443c5591 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/delete_test.go @@ -0,0 +1,210 @@ +package delete + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + tty bool + input string + output DeleteOptions + wantErr bool + wantErrMsg string + }{ + { + name: "tty", + tty: true, + input: "123", + output: DeleteOptions{KeyID: "123", Confirmed: false}, + }, + { + name: "confirm flag tty", + tty: true, + input: "123 --confirm", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "shorthand confirm flag tty", + tty: true, + input: "123 -y", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "no tty", + input: "123", + wantErr: true, + wantErrMsg: "--confirm required when not running interactively", + }, + { + name: "confirm flag no tty", + input: "123 --confirm", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "shorthand confirm flag no tty", + input: "123 -y", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "no args", + input: "", + wantErr: true, + wantErrMsg: "cannot delete: key id required", + }, + { + name: "too many args", + input: "123 456", + wantErr: true, + wantErrMsg: "too many arguments", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + f := &cmdutil.Factory{ + IOStreams: ios, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + + var cmdOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + cmdOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.Error(t, err) + assert.EqualError(t, err, tt.wantErrMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.output.KeyID, cmdOpts.KeyID) + assert.Equal(t, tt.output.Confirmed, cmdOpts.Confirmed) + }) + } +} + +func Test_deleteRun(t *testing.T) { + keyResp := "{\"title\":\"My Key\"}" + tests := []struct { + name string + tty bool + opts DeleteOptions + httpStubs func(*httpmock.Registry) + prompterStubs func(*prompter.PrompterMock) + wantStdout string + wantErr bool + wantErrMsg string + }{ + { + name: "delete tty", + tty: true, + opts: DeleteOptions{KeyID: "123"}, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.ConfirmDeletionFunc = func(_ string) error { + return nil + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "✓ SSH key \"My Key\" (123) deleted from your account\n", + }, + { + name: "delete with confirm flag tty", + tty: true, + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "✓ SSH key \"My Key\" (123) deleted from your account\n", + }, + { + name: "not found tty", + tty: true, + opts: DeleteOptions{KeyID: "123"}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(404, "")) + }, + wantErr: true, + wantErrMsg: "HTTP 404 (https://api.github.com/user/keys/123)", + }, + { + name: "delete no tty", + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "", + }, + { + name: "not found no tty", + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(404, "")) + }, + wantErr: true, + wantErrMsg: "HTTP 404 (https://api.github.com/user/keys/123)", + }, + } + + for _, tt := range tests { + pm := &prompter.PrompterMock{} + if tt.prompterStubs != nil { + tt.prompterStubs(pm) + } + tt.opts.Prompter = pm + + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + ios, _, stdout, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + tt.opts.IO = ios + + t.Run(tt.name, func(t *testing.T) { + err := deleteRun(&tt.opts) + reg.Verify(t) + if tt.wantErr { + assert.Error(t, err) + assert.EqualError(t, err, tt.wantErrMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantStdout, stdout.String()) + }) + } +} diff --git a/pkg/cmd/ssh-key/delete/http.go b/pkg/cmd/ssh-key/delete/http.go new file mode 100644 index 00000000000..906ae6bc906 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/http.go @@ -0,0 +1,66 @@ +package delete + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" +) + +type sshKey struct { + Title string +} + +func deleteSSHKey(httpClient *http.Client, host string, keyID string) error { + url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + return nil +} + +func getSSHKey(httpClient *http.Client, host string, keyID string) (*sshKey, error) { + url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var key sshKey + err = json.Unmarshal(b, &key) + if err != nil { + return nil, err + } + + return &key, nil +} diff --git a/pkg/cmd/ssh-key/ssh_key.go b/pkg/cmd/ssh-key/ssh_key.go index 312aeb77932..809985737fe 100644 --- a/pkg/cmd/ssh-key/ssh_key.go +++ b/pkg/cmd/ssh-key/ssh_key.go @@ -2,6 +2,7 @@ package key import ( cmdAdd "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/ssh-key/delete" cmdList "github.com/cli/cli/v2/pkg/cmd/ssh-key/list" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -14,8 +15,9 @@ func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command { Long: "Manage SSH keys registered with your GitHub account.", } - cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) return cmd }