ContainerImage.Pinniped/test/testlib/browsertest/browsertest.go

227 lines
7.5 KiB
Go

// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package browsertest provides integration test helpers for our browser-based tests.
package browsertest
import (
"regexp"
"strings"
"testing"
"time"
"github.com/sclevine/agouti"
"github.com/stretchr/testify/require"
"go.pinniped.dev/test/testlib"
)
const (
operationTimeout = 10 * time.Second
operationPollingInterval = 100 * time.Millisecond
)
// Open a webdriver-driven browser and returns an *agouti.Page to control it. The browser will be automatically
// closed at the end of the current test. It is configured for test purposes with the correct HTTP proxy and
// in a mode that ignore certificate errors.
func Open(t *testing.T) *agouti.Page {
t.Helper()
// make it trivial to run all browser based tests via:
// go test -v -race -count 1 -timeout 0 ./test/integration -run '/_Browser'
require.Contains(t, rootTestName(t), "_Browser", "browser based tests must contain the string _Browser in their name")
t.Logf("opening browser driver")
env := testlib.IntegrationEnv(t)
caps := agouti.NewCapabilities()
// Capture console.log(), not just console.error().
caps["loggingPrefs"] = map[string]string{"browser": "INFO"}
if env.Proxy != "" {
t.Logf("configuring Chrome to use proxy %q", env.Proxy)
caps = caps.Proxy(agouti.ProxyConfig{
ProxyType: "manual",
HTTPProxy: env.Proxy,
SSLProxy: env.Proxy,
NoProxy: "127.0.0.1",
})
}
agoutiDriver := agouti.ChromeDriver(
agouti.Desired(caps),
agouti.ChromeOptions("args", []string{
"--no-sandbox",
"--ignore-certificate-errors",
"--headless", // Comment out this line to see the tests happen in a visible browser window.
}),
// Uncomment this to see stdout/stderr from chromedriver.
// agouti.Debug,
)
require.NoError(t, agoutiDriver.Start())
t.Cleanup(func() { require.NoError(t, agoutiDriver.Stop()) })
page, err := agoutiDriver.NewPage(agouti.Browser("chrome"))
require.NoError(t, err)
require.NoError(t, page.Reset())
return page
}
func rootTestName(t *testing.T) string {
switch names := strings.SplitN(t.Name(), "/", 3); len(names) {
case 0:
panic("impossible")
case 1:
return names[0]
case 2, 3:
if strings.HasPrefix(names[0], "TestIntegration") {
return names[1]
}
return names[0]
default:
panic("impossible")
}
}
// WaitForVisibleElements expects the page to contain all the the elements specified by the selectors. It waits for this
// to occur and times out, failing the test, if they never appear.
func WaitForVisibleElements(t *testing.T, page *agouti.Page, selectors ...string) {
t.Helper()
testlib.RequireEventuallyf(t,
func(requireEventually *require.Assertions) {
for _, sel := range selectors {
vis, err := page.First(sel).Visible()
requireEventually.NoError(err)
requireEventually.Truef(vis, "expected element %q to be visible", sel)
}
},
operationTimeout,
operationPollingInterval,
"expected to have a page with selectors %v, but it never loaded",
selectors,
)
}
// WaitForURL expects the page to eventually navigate to a URL matching the specified pattern. It waits for this
// to occur and times out, failing the test, if it never does.
func WaitForURL(t *testing.T, page *agouti.Page, pat *regexp.Regexp) {
var lastURL string
testlib.RequireEventuallyf(t,
func(requireEventually *require.Assertions) {
url, err := page.URL()
if url != lastURL {
t.Logf("saw URL %s", testlib.MaskTokens(url))
lastURL = url
}
requireEventually.NoError(err)
requireEventually.Regexp(pat, url)
},
operationTimeout,
operationPollingInterval,
"expected to browse to %s, but never got there",
pat,
)
}
// LoginToUpstreamOIDC expects the page to be redirected to one of several known upstream IDPs.
// It knows how to enter the test username/password and submit the upstream login form.
func LoginToUpstreamOIDC(t *testing.T, page *agouti.Page, upstream testlib.TestOIDCUpstream) {
t.Helper()
type config struct {
Name string
IssuerPattern *regexp.Regexp
LoginPagePattern *regexp.Regexp
UsernameSelector string
PasswordSelector string
LoginButtonSelector string
}
// Lookup the provider by matching on the issuer URL.
var cfg *config
for _, p := range []*config{
{
Name: "Okta",
IssuerPattern: regexp.MustCompile(`\Ahttps://.+\.okta\.com/.+\z`),
LoginPagePattern: regexp.MustCompile(`\Ahttps://.+\.okta\.com/.*\z`),
UsernameSelector: "input#okta-signin-username",
PasswordSelector: "input#okta-signin-password",
LoginButtonSelector: "input#okta-signin-submit",
},
{
Name: "Dex",
IssuerPattern: regexp.MustCompile(`\Ahttps://dex\.tools\.svc\.cluster\.local/dex.*\z`),
LoginPagePattern: regexp.MustCompile(`\Ahttps://dex\.tools\.svc\.cluster\.local/dex/auth/local.+\z`),
UsernameSelector: "input#login",
PasswordSelector: "input#password",
LoginButtonSelector: "button#submit-login",
},
} {
if p.IssuerPattern.MatchString(upstream.Issuer) {
cfg = p
break
}
}
if cfg == nil {
require.Failf(t, "could not find login provider for issuer %q", upstream.Issuer)
return
}
// Expect to be redirected to the login page.
t.Logf("waiting for redirect to %s login page", cfg.Name)
WaitForURL(t, page, cfg.LoginPagePattern)
// Wait for the login page to be rendered.
WaitForVisibleElements(t, page, cfg.UsernameSelector, cfg.PasswordSelector, cfg.LoginButtonSelector)
// Fill in the username and password and click "submit".
t.Logf("logging into %s", cfg.Name)
require.NoError(t, page.First(cfg.UsernameSelector).Fill(upstream.Username))
require.NoError(t, page.First(cfg.PasswordSelector).Fill(upstream.Password))
require.NoError(t, page.First(cfg.LoginButtonSelector).Click())
}
// LoginToUpstreamLDAP expects the page to be redirected to the Supervisor's login UI for an LDAP/AD IDP.
// It knows how to enter the test username/password and submit the upstream login form.
func LoginToUpstreamLDAP(t *testing.T, page *agouti.Page, issuer, username, password string) {
t.Helper()
loginURLRegexp, err := regexp.Compile(`\A` + regexp.QuoteMeta(issuer+"/login") + `\?state=.+\z`)
require.NoError(t, err)
// Expect to be redirected to the login page.
t.Logf("waiting for redirect to %s/login page", issuer)
WaitForURL(t, page, loginURLRegexp)
// Wait for the login page to be rendered.
WaitForVisibleElements(t, page, "#username", "#password", "#submit")
// Fill in the username and password and click "submit".
SubmitUpstreamLDAPLoginForm(t, page, username, password)
}
func SubmitUpstreamLDAPLoginForm(t *testing.T, page *agouti.Page, username string, password string) {
t.Helper()
// Fill in the username and password and click "submit".
t.Logf("logging in via Supervisor's upstream LDAP/AD login UI page")
require.NoError(t, page.First("#username").Fill(username))
require.NoError(t, page.First("#password").Fill(password))
require.NoError(t, page.First("#submit").Click())
}
func WaitForUpstreamLDAPLoginPageWithError(t *testing.T, page *agouti.Page, issuer string) {
t.Helper()
// Wait for redirect back to the login page again with an error.
t.Logf("waiting for redirect to back to login page with error message")
loginURLRegexp, err := regexp.Compile(`\A` + regexp.QuoteMeta(issuer+"/login") + `\?err=login_error&state=.+\z`)
require.NoError(t, err)
WaitForURL(t, page, loginURLRegexp)
// Wait for the login page to be rendered again, this time also with an error message.
WaitForVisibleElements(t, page, "#username", "#password", "#submit", "#alert")
}