github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/auth/login/login_test.go (about)

     1  package login
     2  
     3  import (
     4  	"bytes"
     5  	"net/http"
     6  	"regexp"
     7  	"runtime"
     8  	"testing"
     9  
    10  	"github.com/MakeNowJust/heredoc"
    11  	"github.com/ungtb10d/cli/v2/git"
    12  	"github.com/ungtb10d/cli/v2/internal/config"
    13  	"github.com/ungtb10d/cli/v2/internal/prompter"
    14  	"github.com/ungtb10d/cli/v2/internal/run"
    15  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    16  	"github.com/ungtb10d/cli/v2/pkg/httpmock"
    17  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    18  	"github.com/google/shlex"
    19  	"github.com/stretchr/testify/assert"
    20  )
    21  
    22  func stubHomeDir(t *testing.T, dir string) {
    23  	homeEnv := "HOME"
    24  	switch runtime.GOOS {
    25  	case "windows":
    26  		homeEnv = "USERPROFILE"
    27  	case "plan9":
    28  		homeEnv = "home"
    29  	}
    30  	t.Setenv(homeEnv, dir)
    31  }
    32  
    33  func Test_NewCmdLogin(t *testing.T) {
    34  	tests := []struct {
    35  		name        string
    36  		cli         string
    37  		stdin       string
    38  		stdinTTY    bool
    39  		defaultHost string
    40  		wants       LoginOptions
    41  		wantsErr    bool
    42  	}{
    43  		{
    44  			name:        "nontty, with-token",
    45  			stdin:       "abc123\n",
    46  			cli:         "--with-token",
    47  			defaultHost: "github.com",
    48  			wants: LoginOptions{
    49  				Hostname: "github.com",
    50  				Token:    "abc123",
    51  			},
    52  		},
    53  		{
    54  			name:        "nontty, Enterprise host",
    55  			stdin:       "abc123\n",
    56  			cli:         "--with-token",
    57  			defaultHost: "git.example.com",
    58  			wants: LoginOptions{
    59  				Hostname: "git.example.com",
    60  				Token:    "abc123",
    61  			},
    62  		},
    63  		{
    64  			name:     "tty, with-token",
    65  			stdinTTY: true,
    66  			stdin:    "def456",
    67  			cli:      "--with-token",
    68  			wants: LoginOptions{
    69  				Hostname: "github.com",
    70  				Token:    "def456",
    71  			},
    72  		},
    73  		{
    74  			name:     "nontty, hostname",
    75  			stdinTTY: false,
    76  			cli:      "--hostname claire.redfield",
    77  			wants: LoginOptions{
    78  				Hostname: "claire.redfield",
    79  				Token:    "",
    80  			},
    81  		},
    82  		{
    83  			name:     "nontty",
    84  			stdinTTY: false,
    85  			cli:      "",
    86  			wants: LoginOptions{
    87  				Hostname: "github.com",
    88  				Token:    "",
    89  			},
    90  		},
    91  		{
    92  			name:  "nontty, with-token, hostname",
    93  			cli:   "--hostname claire.redfield --with-token",
    94  			stdin: "abc123\n",
    95  			wants: LoginOptions{
    96  				Hostname: "claire.redfield",
    97  				Token:    "abc123",
    98  			},
    99  		},
   100  		{
   101  			name:     "tty, with-token, hostname",
   102  			stdinTTY: true,
   103  			stdin:    "ghi789",
   104  			cli:      "--with-token --hostname brad.vickers",
   105  			wants: LoginOptions{
   106  				Hostname: "brad.vickers",
   107  				Token:    "ghi789",
   108  			},
   109  		},
   110  		{
   111  			name:     "tty, hostname",
   112  			stdinTTY: true,
   113  			cli:      "--hostname barry.burton",
   114  			wants: LoginOptions{
   115  				Hostname:    "barry.burton",
   116  				Token:       "",
   117  				Interactive: true,
   118  			},
   119  		},
   120  		{
   121  			name:     "tty",
   122  			stdinTTY: true,
   123  			cli:      "",
   124  			wants: LoginOptions{
   125  				Hostname:    "",
   126  				Token:       "",
   127  				Interactive: true,
   128  			},
   129  		},
   130  		{
   131  			name:     "tty web",
   132  			stdinTTY: true,
   133  			cli:      "--web",
   134  			wants: LoginOptions{
   135  				Hostname:    "github.com",
   136  				Web:         true,
   137  				Interactive: true,
   138  			},
   139  		},
   140  		{
   141  			name: "nontty web",
   142  			cli:  "--web",
   143  			wants: LoginOptions{
   144  				Hostname: "github.com",
   145  				Web:      true,
   146  			},
   147  		},
   148  		{
   149  			name:     "web and with-token",
   150  			cli:      "--web --with-token",
   151  			wantsErr: true,
   152  		},
   153  		{
   154  			name:     "tty one scope",
   155  			stdinTTY: true,
   156  			cli:      "--scopes repo:invite",
   157  			wants: LoginOptions{
   158  				Hostname:    "",
   159  				Scopes:      []string{"repo:invite"},
   160  				Token:       "",
   161  				Interactive: true,
   162  			},
   163  		},
   164  		{
   165  			name:     "tty scopes",
   166  			stdinTTY: true,
   167  			cli:      "--scopes repo:invite,read:public_key",
   168  			wants: LoginOptions{
   169  				Hostname:    "",
   170  				Scopes:      []string{"repo:invite", "read:public_key"},
   171  				Token:       "",
   172  				Interactive: true,
   173  			},
   174  		},
   175  	}
   176  
   177  	for _, tt := range tests {
   178  		t.Run(tt.name, func(t *testing.T) {
   179  			t.Setenv("GH_HOST", tt.defaultHost)
   180  
   181  			ios, stdin, _, _ := iostreams.Test()
   182  			f := &cmdutil.Factory{
   183  				IOStreams: ios,
   184  			}
   185  
   186  			ios.SetStdoutTTY(true)
   187  			ios.SetStdinTTY(tt.stdinTTY)
   188  			if tt.stdin != "" {
   189  				stdin.WriteString(tt.stdin)
   190  			}
   191  
   192  			argv, err := shlex.Split(tt.cli)
   193  			assert.NoError(t, err)
   194  
   195  			var gotOpts *LoginOptions
   196  			cmd := NewCmdLogin(f, func(opts *LoginOptions) error {
   197  				gotOpts = opts
   198  				return nil
   199  			})
   200  			// TODO cobra hack-around
   201  			cmd.Flags().BoolP("help", "x", false, "")
   202  
   203  			cmd.SetArgs(argv)
   204  			cmd.SetIn(&bytes.Buffer{})
   205  			cmd.SetOut(&bytes.Buffer{})
   206  			cmd.SetErr(&bytes.Buffer{})
   207  
   208  			_, err = cmd.ExecuteC()
   209  			if tt.wantsErr {
   210  				assert.Error(t, err)
   211  				return
   212  			}
   213  			assert.NoError(t, err)
   214  
   215  			assert.Equal(t, tt.wants.Token, gotOpts.Token)
   216  			assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
   217  			assert.Equal(t, tt.wants.Web, gotOpts.Web)
   218  			assert.Equal(t, tt.wants.Interactive, gotOpts.Interactive)
   219  			assert.Equal(t, tt.wants.Scopes, gotOpts.Scopes)
   220  		})
   221  	}
   222  }
   223  
   224  func Test_loginRun_nontty(t *testing.T) {
   225  	tests := []struct {
   226  		name       string
   227  		opts       *LoginOptions
   228  		httpStubs  func(*httpmock.Registry)
   229  		cfgStubs   func(*config.ConfigMock)
   230  		wantHosts  string
   231  		wantErr    string
   232  		wantStderr string
   233  	}{
   234  		{
   235  			name: "with token",
   236  			opts: &LoginOptions{
   237  				Hostname: "github.com",
   238  				Token:    "abc123",
   239  			},
   240  			httpStubs: func(reg *httpmock.Registry) {
   241  				reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
   242  			},
   243  			wantHosts: "github.com:\n    oauth_token: abc123\n",
   244  		},
   245  		{
   246  			name: "with token and https git-protocol",
   247  			opts: &LoginOptions{
   248  				Hostname:    "github.com",
   249  				Token:       "abc123",
   250  				GitProtocol: "https",
   251  			},
   252  			httpStubs: func(reg *httpmock.Registry) {
   253  				reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
   254  			},
   255  			wantHosts: "github.com:\n    oauth_token: abc123\n    git_protocol: https\n",
   256  		},
   257  		{
   258  			name: "with token and non-default host",
   259  			opts: &LoginOptions{
   260  				Hostname: "albert.wesker",
   261  				Token:    "abc123",
   262  			},
   263  			httpStubs: func(reg *httpmock.Registry) {
   264  				reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
   265  			},
   266  			wantHosts: "albert.wesker:\n    oauth_token: abc123\n",
   267  		},
   268  		{
   269  			name: "missing repo scope",
   270  			opts: &LoginOptions{
   271  				Hostname: "github.com",
   272  				Token:    "abc456",
   273  			},
   274  			httpStubs: func(reg *httpmock.Registry) {
   275  				reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org"))
   276  			},
   277  			wantErr: `error validating token: missing required scope 'repo'`,
   278  		},
   279  		{
   280  			name: "missing read scope",
   281  			opts: &LoginOptions{
   282  				Hostname: "github.com",
   283  				Token:    "abc456",
   284  			},
   285  			httpStubs: func(reg *httpmock.Registry) {
   286  				reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo"))
   287  			},
   288  			wantErr: `error validating token: missing required scope 'read:org'`,
   289  		},
   290  		{
   291  			name: "has admin scope",
   292  			opts: &LoginOptions{
   293  				Hostname: "github.com",
   294  				Token:    "abc456",
   295  			},
   296  			httpStubs: func(reg *httpmock.Registry) {
   297  				reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org"))
   298  			},
   299  			wantHosts: "github.com:\n    oauth_token: abc456\n",
   300  		},
   301  		{
   302  			name: "github.com token from environment",
   303  			opts: &LoginOptions{
   304  				Hostname: "github.com",
   305  				Token:    "abc456",
   306  			},
   307  			cfgStubs: func(c *config.ConfigMock) {
   308  				c.AuthTokenFunc = func(string) (string, string) {
   309  					return "value_from_env", "GH_TOKEN"
   310  				}
   311  			},
   312  			wantErr: "SilentError",
   313  			wantStderr: heredoc.Doc(`
   314  				The value of the GH_TOKEN environment variable is being used for authentication.
   315  				To have GitHub CLI store credentials instead, first clear the value from the environment.
   316  			`),
   317  		},
   318  		{
   319  			name: "GHE token from environment",
   320  			opts: &LoginOptions{
   321  				Hostname: "ghe.io",
   322  				Token:    "abc456",
   323  			},
   324  			cfgStubs: func(c *config.ConfigMock) {
   325  				c.AuthTokenFunc = func(string) (string, string) {
   326  					return "value_from_env", "GH_ENTERPRISE_TOKEN"
   327  				}
   328  			},
   329  			wantErr: "SilentError",
   330  			wantStderr: heredoc.Doc(`
   331  				The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication.
   332  				To have GitHub CLI store credentials instead, first clear the value from the environment.
   333  			`),
   334  		},
   335  	}
   336  
   337  	for _, tt := range tests {
   338  		ios, _, stdout, stderr := iostreams.Test()
   339  		ios.SetStdinTTY(false)
   340  		ios.SetStdoutTTY(false)
   341  		tt.opts.IO = ios
   342  
   343  		t.Run(tt.name, func(t *testing.T) {
   344  			readConfigs := config.StubWriteConfig(t)
   345  			cfg := config.NewBlankConfig()
   346  			if tt.cfgStubs != nil {
   347  				tt.cfgStubs(cfg)
   348  			}
   349  			tt.opts.Config = func() (config.Config, error) {
   350  				return cfg, nil
   351  			}
   352  
   353  			reg := &httpmock.Registry{}
   354  			tt.opts.HttpClient = func() (*http.Client, error) {
   355  				return &http.Client{Transport: reg}, nil
   356  			}
   357  			if tt.httpStubs != nil {
   358  				tt.httpStubs(reg)
   359  			}
   360  
   361  			_, restoreRun := run.Stub()
   362  			defer restoreRun(t)
   363  
   364  			err := loginRun(tt.opts)
   365  			if tt.wantErr != "" {
   366  				assert.EqualError(t, err, tt.wantErr)
   367  			} else {
   368  				assert.NoError(t, err)
   369  			}
   370  
   371  			mainBuf := bytes.Buffer{}
   372  			hostsBuf := bytes.Buffer{}
   373  			readConfigs(&mainBuf, &hostsBuf)
   374  
   375  			assert.Equal(t, "", stdout.String())
   376  			assert.Equal(t, tt.wantStderr, stderr.String())
   377  			assert.Equal(t, tt.wantHosts, hostsBuf.String())
   378  			reg.Verify(t)
   379  		})
   380  	}
   381  }
   382  
   383  func Test_loginRun_Survey(t *testing.T) {
   384  	stubHomeDir(t, t.TempDir())
   385  
   386  	tests := []struct {
   387  		name          string
   388  		opts          *LoginOptions
   389  		httpStubs     func(*httpmock.Registry)
   390  		prompterStubs func(*prompter.PrompterMock)
   391  		runStubs      func(*run.CommandStubber)
   392  		wantHosts     string
   393  		wantErrOut    *regexp.Regexp
   394  		cfgStubs      func(*config.ConfigMock)
   395  	}{
   396  		{
   397  			name: "already authenticated",
   398  			opts: &LoginOptions{
   399  				Interactive: true,
   400  			},
   401  			cfgStubs: func(c *config.ConfigMock) {
   402  				c.AuthTokenFunc = func(h string) (string, string) {
   403  					return "ghi789", "oauth_token"
   404  				}
   405  			},
   406  			httpStubs: func(reg *httpmock.Registry) {
   407  				reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
   408  			},
   409  			prompterStubs: func(pm *prompter.PrompterMock) {
   410  				pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
   411  					if prompt == "What account do you want to log into?" {
   412  						return prompter.IndexFor(opts, "GitHub.com")
   413  					}
   414  					return -1, prompter.NoSuchPromptErr(prompt)
   415  				}
   416  			},
   417  			wantHosts:  "",
   418  			wantErrOut: nil,
   419  		},
   420  		{
   421  			name: "hostname set",
   422  			opts: &LoginOptions{
   423  				Hostname:    "rebecca.chambers",
   424  				Interactive: true,
   425  			},
   426  			wantHosts: heredoc.Doc(`
   427  				rebecca.chambers:
   428  				    oauth_token: def456
   429  				    user: jillv
   430  				    git_protocol: https
   431  			`),
   432  			prompterStubs: func(pm *prompter.PrompterMock) {
   433  				pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
   434  					switch prompt {
   435  					case "What is your preferred protocol for Git operations?":
   436  						return prompter.IndexFor(opts, "HTTPS")
   437  					case "How would you like to authenticate GitHub CLI?":
   438  						return prompter.IndexFor(opts, "Paste an authentication token")
   439  					}
   440  					return -1, prompter.NoSuchPromptErr(prompt)
   441  				}
   442  			},
   443  			runStubs: func(rs *run.CommandStubber) {
   444  				rs.Register(`git config credential\.https:/`, 1, "")
   445  				rs.Register(`git config credential\.helper`, 1, "")
   446  			},
   447  			httpStubs: func(reg *httpmock.Registry) {
   448  				reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
   449  				reg.Register(
   450  					httpmock.GraphQL(`query UserCurrent\b`),
   451  					httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
   452  			},
   453  			wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://rebecca.chambers/settings/tokens"),
   454  		},
   455  		{
   456  			name: "choose enterprise",
   457  			wantHosts: heredoc.Doc(`
   458  				brad.vickers:
   459  				    oauth_token: def456
   460  				    user: jillv
   461  				    git_protocol: https
   462  			`),
   463  			opts: &LoginOptions{
   464  				Interactive: true,
   465  			},
   466  			prompterStubs: func(pm *prompter.PrompterMock) {
   467  				pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
   468  					switch prompt {
   469  					case "What account do you want to log into?":
   470  						return prompter.IndexFor(opts, "GitHub Enterprise Server")
   471  					case "What is your preferred protocol for Git operations?":
   472  						return prompter.IndexFor(opts, "HTTPS")
   473  					case "How would you like to authenticate GitHub CLI?":
   474  						return prompter.IndexFor(opts, "Paste an authentication token")
   475  					}
   476  					return -1, prompter.NoSuchPromptErr(prompt)
   477  				}
   478  				pm.InputHostnameFunc = func() (string, error) {
   479  					return "brad.vickers", nil
   480  				}
   481  			},
   482  			runStubs: func(rs *run.CommandStubber) {
   483  				rs.Register(`git config credential\.https:/`, 1, "")
   484  				rs.Register(`git config credential\.helper`, 1, "")
   485  			},
   486  			httpStubs: func(reg *httpmock.Registry) {
   487  				reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
   488  				reg.Register(
   489  					httpmock.GraphQL(`query UserCurrent\b`),
   490  					httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
   491  			},
   492  			wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://brad.vickers/settings/tokens"),
   493  		},
   494  		{
   495  			name: "choose github.com",
   496  			wantHosts: heredoc.Doc(`
   497  				github.com:
   498  				    oauth_token: def456
   499  				    user: jillv
   500  				    git_protocol: https
   501  			`),
   502  			opts: &LoginOptions{
   503  				Interactive: true,
   504  			},
   505  			prompterStubs: func(pm *prompter.PrompterMock) {
   506  				pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
   507  					switch prompt {
   508  					case "What account do you want to log into?":
   509  						return prompter.IndexFor(opts, "GitHub.com")
   510  					case "What is your preferred protocol for Git operations?":
   511  						return prompter.IndexFor(opts, "HTTPS")
   512  					case "How would you like to authenticate GitHub CLI?":
   513  						return prompter.IndexFor(opts, "Paste an authentication token")
   514  					}
   515  					return -1, prompter.NoSuchPromptErr(prompt)
   516  				}
   517  			},
   518  			runStubs: func(rs *run.CommandStubber) {
   519  				rs.Register(`git config credential\.https:/`, 1, "")
   520  				rs.Register(`git config credential\.helper`, 1, "")
   521  			},
   522  			wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"),
   523  		},
   524  		{
   525  			name: "sets git_protocol",
   526  			wantHosts: heredoc.Doc(`
   527  				github.com:
   528  				    oauth_token: def456
   529  				    user: jillv
   530  				    git_protocol: ssh
   531  			`),
   532  			opts: &LoginOptions{
   533  				Interactive: true,
   534  			},
   535  			prompterStubs: func(pm *prompter.PrompterMock) {
   536  				pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
   537  					switch prompt {
   538  					case "What account do you want to log into?":
   539  						return prompter.IndexFor(opts, "GitHub.com")
   540  					case "What is your preferred protocol for Git operations?":
   541  						return prompter.IndexFor(opts, "SSH")
   542  					case "How would you like to authenticate GitHub CLI?":
   543  						return prompter.IndexFor(opts, "Paste an authentication token")
   544  					}
   545  					return -1, prompter.NoSuchPromptErr(prompt)
   546  				}
   547  			},
   548  			wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"),
   549  		},
   550  		// TODO how to test browser auth?
   551  	}
   552  
   553  	for _, tt := range tests {
   554  		if tt.opts == nil {
   555  			tt.opts = &LoginOptions{}
   556  		}
   557  		ios, _, _, stderr := iostreams.Test()
   558  
   559  		ios.SetStdinTTY(true)
   560  		ios.SetStderrTTY(true)
   561  		ios.SetStdoutTTY(true)
   562  
   563  		tt.opts.IO = ios
   564  
   565  		readConfigs := config.StubWriteConfig(t)
   566  
   567  		cfg := config.NewBlankConfig()
   568  		if tt.cfgStubs != nil {
   569  			tt.cfgStubs(cfg)
   570  		}
   571  		tt.opts.Config = func() (config.Config, error) {
   572  			return cfg, nil
   573  		}
   574  
   575  		t.Run(tt.name, func(t *testing.T) {
   576  			reg := &httpmock.Registry{}
   577  			tt.opts.HttpClient = func() (*http.Client, error) {
   578  				return &http.Client{Transport: reg}, nil
   579  			}
   580  			if tt.httpStubs != nil {
   581  				tt.httpStubs(reg)
   582  			} else {
   583  				reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
   584  				reg.Register(
   585  					httpmock.GraphQL(`query UserCurrent\b`),
   586  					httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
   587  			}
   588  
   589  			pm := &prompter.PrompterMock{}
   590  			pm.ConfirmFunc = func(_ string, _ bool) (bool, error) {
   591  				return false, nil
   592  			}
   593  			pm.AuthTokenFunc = func() (string, error) {
   594  				return "def456", nil
   595  			}
   596  			if tt.prompterStubs != nil {
   597  				tt.prompterStubs(pm)
   598  			}
   599  			tt.opts.Prompter = pm
   600  
   601  			tt.opts.GitClient = &git.Client{GitPath: "some/path/git"}
   602  
   603  			rs, restoreRun := run.Stub()
   604  			defer restoreRun(t)
   605  			if tt.runStubs != nil {
   606  				tt.runStubs(rs)
   607  			}
   608  
   609  			err := loginRun(tt.opts)
   610  			if err != nil {
   611  				t.Fatalf("unexpected error: %s", err)
   612  			}
   613  
   614  			mainBuf := bytes.Buffer{}
   615  			hostsBuf := bytes.Buffer{}
   616  			readConfigs(&mainBuf, &hostsBuf)
   617  
   618  			assert.Equal(t, tt.wantHosts, hostsBuf.String())
   619  			if tt.wantErrOut == nil {
   620  				assert.Equal(t, "", stderr.String())
   621  			} else {
   622  				assert.Regexp(t, tt.wantErrOut, stderr.String())
   623  			}
   624  			reg.Verify(t)
   625  		})
   626  	}
   627  }