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 }