From 7c85a511a2d1ab17207b5845ae38d36f16245460 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 8 May 2024 16:38:49 -0700 Subject: [PATCH] first draft of an e2e integration test for GitHub login (skip while WIP) --- hack/prepare-for-integration-tests.sh | 23 ++++- .../resolved_github_provider_test.go | 3 + internal/testutil/totp/totp.go | 84 +++++++++++++++++ test/integration/e2e_test.go | 89 +++++++++++++++++-- test/integration/supervisor_discovery_test.go | 2 +- test/integration/supervisor_login_test.go | 2 +- test/integration/supervisor_upstream_test.go | 6 +- test/integration/supervisor_warnings_test.go | 2 +- test/testlib/browsertest/browsertest.go | 80 +++++++++++++++++ test/testlib/client.go | 58 +++++++++++- test/testlib/env.go | 27 ++++-- 11 files changed, 355 insertions(+), 21 deletions(-) create mode 100644 internal/testutil/totp/totp.go diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index 7159b9a15..24a6acec4 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +# Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -31,6 +31,7 @@ clean_kind=no api_group_suffix="pinniped.dev" # same default as in the values.yaml ytt file dockerfile_path="" get_active_directory_vars="" # specify a filename for a script to get AD related env variables +get_github_vars="" # specify a filename for a script to get GitHub related env variables alternate_deploy="undefined" pre_install="undefined" @@ -68,6 +69,16 @@ while (("$#")); do get_active_directory_vars=$1 shift ;; + --get-github-vars) + shift + # If there are no more command line arguments, or there is another command line argument but it starts with a dash, then error + if [[ "$#" == "0" || "$1" == -* ]]; then + log_error "--get-github-vars requires a script name to be specified" + exit 1 + fi + get_github_vars=$1 + shift + ;; --dockerfile-path) shift # If there are no more command line arguments, or there is another command line argument but it starts with a dash, then error @@ -121,6 +132,7 @@ if [[ "$help" == "yes" ]]; then log_note " -g, --api-group-suffix: deploy Pinniped with an alternate API group suffix" log_note " -s, --skip-build: reuse the most recently built image of the app instead of building" log_note " -a, --get-active-directory-vars: specify a script that exports active directory environment variables" + log_note " --get-github-vars: specify a script that exports GitHub environment variables" log_note " --alternate-deploy: specify an alternate deploy script to install all components of Pinniped" log_note " --pre-install: specify an pre-install script such as a build script" exit 1 @@ -453,6 +465,15 @@ if [[ "$get_active_directory_vars" != "" ]]; then source $get_active_directory_vars fi +# We can't set up an in-cluster GitHub instance, but +# if you have a GitHub account that you wish to run the tests against, +# specify a script to set the GitHub environment variables. +# You will need to set the environment variables that start with "PINNIPED_TEST_GITHUB_" +# found in pinniped/test/testlib/env.go. +if [[ "$get_github_vars" != "" ]]; then + source $get_github_vars +fi + read -r -d '' PINNIPED_TEST_CLUSTER_CAPABILITY_YAML << PINNIPED_TEST_CLUSTER_CAPABILITY_YAML_EOF || true ${pinniped_cluster_capability_file_content} PINNIPED_TEST_CLUSTER_CAPABILITY_YAML_EOF diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go index fcba5e3a3..bf6d98a52 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go @@ -87,6 +87,9 @@ func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { "https://localhost/fake/path", ) require.NoError(t, err) + // Note that GitHub does not require (or document) the standard response_type=code param, but in manual testing + // of GitHub authorize endpoint, it seems to ignore the param. The oauth2 package wants to add the param, so + // we will let it. require.Equal(t, "https://fake-authorization-url?"+ "client_id=fake-client-id&"+ diff --git a/internal/testutil/totp/totp.go b/internal/testutil/totp/totp.go new file mode 100644 index 000000000..7951b07ce --- /dev/null +++ b/internal/testutil/totp/totp.go @@ -0,0 +1,84 @@ +package totp + +import ( + "crypto/hmac" + "crypto/sha1" //nolint:gosec // This is an implementation of an RFC that used SHA-1 + "encoding/base32" + "encoding/binary" + "fmt" + "github.com/stretchr/testify/require" + "math" + "strings" + "testing" + "time" +) + +// This code is borrowed from +// https://github.com/yitsushi/totp-cli/blob/b26f5673ae2e5cc682fc1f5ed771cb08a6403283/internal/security/otp.go +// and +// https://github.com/yitsushi/totp-cli/blob/b26f5673ae2e5cc682fc1f5ed771cb08a6403283/internal/security/error.go +// which is MIT licensed. The MIT license allows copying. +// We are choosing to copying rather than take on a whole new project dependency just for a small test helper. + +const ( + mask1 = 0xf + mask2 = 0x7f + mask3 = 0xff + timeSplitInSeconds = 30 + shift24 = 24 + shift16 = 16 + shift8 = 8 + sumByteLength = 8 +) + +// OTPError is an error describing an error during generation. +type OTPError struct { + Message string +} + +func (e OTPError) Error() string { + return "otp error: " + e.Message +} + +// GenerateOTPCode generates a 6 digit TOTP from the secret Token. +func GenerateOTPCode(t *testing.T, token string, when time.Time) (string, int64) { + t.Helper() + + require.NotEmpty(t, token) + + timer := uint64(math.Floor(float64(when.Unix()) / float64(timeSplitInSeconds))) + remainingTime := timeSplitInSeconds - when.Unix()%timeSplitInSeconds + + // Remove spaces, some providers are giving us in a readable format, + // so they add spaces in there. If it's not removed while pasting in, + // remove it now. + token = strings.ReplaceAll(token, " ", "") + + // It should be uppercase always + token = strings.ToUpper(token) + + secretBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(token) + require.NoError(t, err) + + length := 6 + + buf := make([]byte, sumByteLength) + mac := hmac.New(sha1.New, secretBytes) + + binary.BigEndian.PutUint64(buf, timer) + _, _ = mac.Write(buf) + sum := mac.Sum(nil) + + // http://tools.ietf.org/html/rfc4226#section-5.4 + offset := sum[len(sum)-1] & mask1 + value := int64(((int(sum[offset]) & mask2) << shift24) | + ((int(sum[offset+1] & mask3)) << shift16) | + ((int(sum[offset+2] & mask3)) << shift8) | + (int(sum[offset+3]) & mask3)) + + modulo := int32(value % int64(math.Pow10(length))) + + format := fmt.Sprintf("%%0%dd", length) + + return fmt.Sprintf(format, modulo), remainingTime +} diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index da42f9e4e..16bf2c323 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -158,7 +158,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) @@ -244,7 +244,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) @@ -332,7 +332,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) @@ -456,7 +456,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) @@ -587,7 +587,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) @@ -660,7 +660,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) @@ -1219,6 +1219,81 @@ func TestE2EFullIntegration_Browser(t *testing.T) { sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) + t.Run("with Supervisor GitHub upstream IDP and browser flow with with form_post automatic authcode delivery to CLI", func(t *testing.T) { + // TODO only skip this test when the GitHub test env vars are not set + t.Skip("always skipping for now, this test is still a work in progress and it always fails at the moment") + + testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + tempDir := t.TempDir() // per-test tmp dir to avoid sharing files between tests + + // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. + browser := browsertest.OpenBrowser(t) + + // TODO create clusterrolebinding for expected user and WaitForUserToHaveAccess. doesn't matter until login fully works. + + // Create upstream GitHub provider and wait for it to become ready. + // TODO use return value when calling requireUserCanUseKubectlWithoutAuthenticatingAgain below + _ = testlib.CreateTestGitHubIdentityProvider(t, idpv1alpha1.GitHubIdentityProviderSpec{ + AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{ + Organizations: idpv1alpha1.GitHubOrganizationsSpec{ + Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers), + }, + }, + Client: idpv1alpha1.GitHubClientSpec{ + SecretName: testlib.CreateGitHubClientCredentialsSecret(t, + env.SupervisorUpstreamGithub.GithubAppClientID, + env.SupervisorUpstreamGithub.GithubAppClientSecret, + ).Name, + }, + }, idpv1alpha1.GitHubPhaseReady) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) + testlib.WaitForJWTAuthenticatorStatusPhase(testCtx, t, authenticator.Name, authv1alpha.JWTAuthenticatorPhaseReady) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" + + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-skip-browser", + "--oidc-ca-bundle", testCABundlePath, + "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, + // use default for --oidc-scopes, which is to request all relevant scopes + }) + + // Run "kubectl get namespaces" which should trigger a browser login via the plugin. + kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath, "-v", "6") + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + + // Run the kubectl command, wait for the Pinniped CLI to print the authorization URL, and open it in the browser. + // TODO use return value when calling requireKubectlGetNamespaceOutput below + _ = startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser) + + // Confirm that we got to the upstream IDP's login page, fill out the form, and submit the form. + browsertest.LoginToUpstreamGitHub(t, browser, env.SupervisorUpstreamGithub) + + // Expect to be redirected to the downstream callback which is serving the form_post HTML. + t.Logf("waiting for response page %s", federationDomain.Spec.Issuer) + browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer))) + + // TODO When you turn off headless and watch this test run, + // the browser is indeed redirected back to the Supervisor at this point with a code, + // but the Supervisor's callback endpoint does not yet work for github IDPs so it returns an error page, + // and the Supervisor's form_post page is not loaded, so it does not automatically post the callback to the CLI's callback listener. + // The test eventually times out and fails at this point. + + // TODO + // formpostExpectSuccessState + // requireKubectlGetNamespaceOutput + // requireUserCanUseKubectlWithoutAuthenticatingAgain + }) + t.Run("with multiple IDPs: one OIDC and one LDAP", func(t *testing.T) { testlib.SkipTestWhenLDAPIsUnavailable(t, env) @@ -1280,7 +1355,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) diff --git a/test/integration/supervisor_discovery_test.go b/test/integration/supervisor_discovery_test.go index 9e8e8536b..340ea728d 100644 --- a/test/integration/supervisor_discovery_test.go +++ b/test/integration/supervisor_discovery_test.go @@ -828,7 +828,7 @@ func requireIDPsListedByIDPDiscoveryEndpoint( Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index ee5802a25..6947568d5 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -68,7 +68,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, } } diff --git a/test/integration/supervisor_upstream_test.go b/test/integration/supervisor_upstream_test.go index fdd21269d..70c805ee1 100644 --- a/test/integration/supervisor_upstream_test.go +++ b/test/integration/supervisor_upstream_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -60,7 +60,7 @@ Get "https://127.0.0.1:444444/invalid-url-that-is-really-really-long-nananananan AdditionalScopes: []string{"email", "profile"}, }, Client: v1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, "test-client-id", "test-client-secret").Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, "test-client-id", "test-client-secret").Name, }, } upstream := testlib.CreateTestOIDCIdentityProvider(t, spec, v1alpha1.PhaseError) @@ -98,7 +98,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + env.Su AdditionalScopes: []string{"email", "profile"}, }, Client: v1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, "test-client-id", "test-client-secret").Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, "test-client-id", "test-client-secret").Name, }, } upstream := testlib.CreateTestOIDCIdentityProvider(t, spec, v1alpha1.PhaseReady) diff --git a/test/integration/supervisor_warnings_test.go b/test/integration/supervisor_warnings_test.go index b07f0a73a..ef321f2d8 100644 --- a/test/integration/supervisor_warnings_test.go +++ b/test/integration/supervisor_warnings_test.go @@ -417,7 +417,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { Groups: env.SupervisorUpstreamOIDC.GroupsClaim, }, Client: idpv1alpha1.OIDCClient{ - SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) testlib.WaitForFederationDomainStatusPhase(ctx, t, downstream.Name, configv1alpha1.FederationDomainPhaseReady) diff --git a/test/testlib/browsertest/browsertest.go b/test/testlib/browsertest/browsertest.go index b9ddfa2e7..4cf1c3de8 100644 --- a/test/testlib/browsertest/browsertest.go +++ b/test/testlib/browsertest/browsertest.go @@ -20,6 +20,7 @@ import ( "github.com/chromedp/chromedp" "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/testutil/totp" "go.pinniped.dev/test/testlib" ) @@ -357,6 +358,85 @@ func LoginToUpstreamOIDC(t *testing.T, b *Browser, upstream testlib.TestOIDCUpst b.ClickFirstMatch(t, cfg.LoginButtonSelector) } +// LoginToUpstreamGitHub expects the page to be redirected to GitHub. +// It knows how to enter the test username/password and submit the upstream login form. +func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithubUpstream) { + t.Helper() + + // Expect to be redirected to the login page. + t.Logf("waiting for redirect to GitHub login page") + b.WaitForURL(t, regexp.MustCompile(`\Ahttps://github\.com/login.+\z`)) + + usernameSelector := "input#login_field" + passwordSelector := "input#password" + loginButtonSelector := "input[type=submit]" + + // Wait for the login page to be rendered. + b.WaitForVisibleElements(t, usernameSelector, passwordSelector, loginButtonSelector) + + // Fill in the username and password and click "submit". + t.Logf("logging into GitHub") + b.SendKeysToFirstMatch(t, usernameSelector, upstream.TestUserUsername) + b.SendKeysToFirstMatch(t, passwordSelector, upstream.TestUserPassword) + b.ClickFirstMatch(t, loginButtonSelector) + + // Next, GitHub should go to a new page and prompt for the six digit MFA/OTP code. + otpSelector := "input#app_totp" + + // Wait for the MFA page to be rendered. + t.Logf("waiting for GitHub MFA page") + b.WaitForVisibleElements(t, otpSelector) + + code, codeRemainingLifetimeSeconds := totp.GenerateOTPCode(t, upstream.TestUserOTPSecret, time.Now()) + if codeRemainingLifetimeSeconds < 2 { + t.Log("sleeping for 2 seconds before generating another OTP code") + time.Sleep(2 * time.Second) + code, _ = totp.GenerateOTPCode(t, upstream.TestUserOTPSecret, time.Now()) + } + + // Fill in the OTP code. We do not need to click "verify" because entering the code automatically submits the page. + t.Logf("entering GitHub OTP code") + b.SendKeysToFirstMatch(t, otpSelector, code) + + t.Log("sleeping for 2 seconds before looking at page title") + time.Sleep(2 * time.Second) + pageTitle := b.Title(t) + t.Logf("saw page title %q", pageTitle) + + // Next Github might go to another page asking if you authorize the GitHub App to act on your behalf, + // if this user has never authorized this app. + if strings.HasPrefix(pageTitle, "Authorize ") { // the title is "Authorize " + // Wait for the authorize app page to be rendered. + t.Logf("waiting for GitHub authorize button") + // There are unfortunately two very similar buttons on this page: + //