|
|
|
@@ -0,0 +1,239 @@
|
|
|
|
|
//go:build browser
|
|
|
|
|
|
|
|
|
|
package e2e
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
cdplog "github.com/chromedp/cdproto/log"
|
|
|
|
|
"github.com/chromedp/cdproto/network"
|
|
|
|
|
"github.com/chromedp/cdproto/page"
|
|
|
|
|
"github.com/chromedp/chromedp"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// acceptDialogs auto-accepts window.confirm/alert dialogs raised in bctx.
|
|
|
|
|
//
|
|
|
|
|
// Needed because ui.js gates destructive actions behind window.confirm via
|
|
|
|
|
// [data-confirm]: with nothing answering the dialog, the page blocks and the click
|
|
|
|
|
// appears to do nothing at all. The handler runs the reply in a goroutine — CDP
|
|
|
|
|
// delivers events on the same loop chromedp.Run uses, so replying inline deadlocks.
|
|
|
|
|
func acceptDialogs(bctx context.Context) {
|
|
|
|
|
chromedp.ListenTarget(bctx, func(ev interface{}) {
|
|
|
|
|
if _, ok := ev.(*page.EventJavascriptDialogOpening); ok {
|
|
|
|
|
go func() { _ = chromedp.Run(bctx, page.HandleJavaScriptDialog(true)) }()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestE2EPasswordRecoveryRoundTrip drives the entire recovery story in a real
|
|
|
|
|
// browser, the way a locked-out user lives it: add an address, confirm it from the
|
|
|
|
|
// link, lose the password, ask for a reset from the sign-in page, follow that link,
|
|
|
|
|
// choose a new password, and sign in with it.
|
|
|
|
|
//
|
|
|
|
|
// The links come out of the captured mail, so nothing here reaches into the store for
|
|
|
|
|
// a token — the browser follows exactly what a recipient would click. Cookies are
|
|
|
|
|
// cleared before the reset half so the visitor really is signed out, which is the only
|
|
|
|
|
// state in which someone reaches for "Forgot password?".
|
|
|
|
|
//
|
|
|
|
|
// It also proves the four new pages run clean under the strict CSP, which no Go test
|
|
|
|
|
// can check.
|
|
|
|
|
func TestE2EPasswordRecoveryRoundTrip(t *testing.T) {
|
|
|
|
|
srv := newE2EServer(t)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
username = "recovery-user"
|
|
|
|
|
address = "recovery@example.test"
|
|
|
|
|
oldPassword = "the-original-password"
|
|
|
|
|
newPassword = "a-completely-new-password"
|
|
|
|
|
)
|
|
|
|
|
// The account starts signed in with a password. Sign-up itself is covered by the
|
|
|
|
|
// signup-emphasis tests; seeding here keeps this test about recovery.
|
|
|
|
|
user, cookie := srv.login(t, username)
|
|
|
|
|
srv.setPassword(t, user.ID, oldPassword)
|
|
|
|
|
|
|
|
|
|
bctx, cancel, watch := startBrowser(t)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
// --- add an address, then confirm it from the emailed link ---------------
|
|
|
|
|
var confirmedState string
|
|
|
|
|
if err := chromedp.Run(bctx,
|
|
|
|
|
network.Enable(),
|
|
|
|
|
cdplog.Enable(),
|
|
|
|
|
page.Enable(),
|
|
|
|
|
setSessionCookie(cookie),
|
|
|
|
|
chromedp.Navigate(srv.authURL+"/account"),
|
|
|
|
|
chromedp.WaitVisible(`#email`, chromedp.ByQuery),
|
|
|
|
|
chromedp.SendKeys(`#email`, address, chromedp.ByQuery),
|
|
|
|
|
chromedp.Click(`[data-testid="email-save"]`, chromedp.ByQuery),
|
|
|
|
|
// The saved-address row is the state we need, and unlike a flash alert it can't
|
|
|
|
|
// be left over from an earlier action.
|
|
|
|
|
chromedp.WaitVisible(`[data-testid="email-remove"]`, chromedp.ByQuery),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("add address: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The confirmation link, read from the message we would have sent.
|
|
|
|
|
verifyPath := srv.mail.lastLink(t)
|
|
|
|
|
if !strings.HasPrefix(verifyPath, "/verify-email/") {
|
|
|
|
|
t.Fatalf("first message linked to %q, want a /verify-email/ link", verifyPath)
|
|
|
|
|
}
|
|
|
|
|
if err := chromedp.Run(bctx,
|
|
|
|
|
chromedp.Navigate(srv.authURL+verifyPath),
|
|
|
|
|
// The confirmation redirects to /account (this browser is signed in); wait for
|
|
|
|
|
// it to land before navigating again, or the next Navigate races the redirect
|
|
|
|
|
// and fails with ERR_ABORTED.
|
|
|
|
|
waitForLocation(srv.authURL+"/account"),
|
|
|
|
|
chromedp.Navigate(srv.authURL+"/account"),
|
|
|
|
|
chromedp.WaitVisible(`[data-testid="email-remove"]`, chromedp.ByQuery),
|
|
|
|
|
chromedp.Text(`.badge`, &confirmedState, chromedp.ByQuery),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("confirm address: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(strings.ToLower(confirmedState), "confirmed") {
|
|
|
|
|
t.Fatalf("address state after following the link = %q, want confirmed", confirmedState)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- locked out: clear the session and reset from the sign-in page --------
|
|
|
|
|
var sentText, resetHeading, successText string
|
|
|
|
|
if err := chromedp.Run(bctx,
|
|
|
|
|
// A locked-out visitor has no session; anything else would skip /login entirely.
|
|
|
|
|
network.ClearBrowserCookies(),
|
|
|
|
|
chromedp.Navigate(srv.authURL+"/login"),
|
|
|
|
|
chromedp.WaitVisible(`[data-testid="forgot-link"]`, chromedp.ByQuery),
|
|
|
|
|
chromedp.Click(`[data-testid="forgot-link"]`, chromedp.ByQuery),
|
|
|
|
|
chromedp.WaitVisible(`#identifier`, chromedp.ByQuery),
|
|
|
|
|
chromedp.SendKeys(`#identifier`, address, chromedp.ByQuery),
|
|
|
|
|
chromedp.Click(`[data-testid="forgot-submit"]`, chromedp.ByQuery),
|
|
|
|
|
// Wait on the URL, not on .card-title: the form page has a card title too, so a
|
|
|
|
|
// selector wait returns instantly and reads the pre-submit heading.
|
|
|
|
|
waitForLocation(srv.authURL+"/forgot?sent=1"),
|
|
|
|
|
chromedp.Text(`.card-title`, &sentText, chromedp.ByQuery),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("request reset: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(sentText, "Check your email") {
|
|
|
|
|
t.Fatalf("after submitting the reset form the page said %q", sentText)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetPath := srv.mail.lastLink(t)
|
|
|
|
|
if !strings.HasPrefix(resetPath, "/reset/") {
|
|
|
|
|
t.Fatalf("second message linked to %q, want a /reset/ link", resetPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := chromedp.Run(bctx,
|
|
|
|
|
chromedp.Navigate(srv.authURL+resetPath),
|
|
|
|
|
chromedp.WaitVisible(`#new_pw`, chromedp.ByQuery),
|
|
|
|
|
// The form must name the account: one address can hold several, each with its
|
|
|
|
|
// own link in the same message.
|
|
|
|
|
chromedp.Text(`.card-body`, &resetHeading, chromedp.ByQuery),
|
|
|
|
|
chromedp.SendKeys(`#new_pw`, newPassword, chromedp.ByQuery),
|
|
|
|
|
chromedp.Click(`[data-testid="reset-submit"]`, chromedp.ByQuery),
|
|
|
|
|
// Lands back on sign-in with the success flash.
|
|
|
|
|
chromedp.WaitVisible(`.alert-success`, chromedp.ByQuery),
|
|
|
|
|
chromedp.Text(`.alert-success`, &successText, chromedp.ByQuery),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("complete reset: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(resetHeading, "@"+username) {
|
|
|
|
|
t.Errorf("reset form didn't name the account (@%s):\n%s", username, resetHeading)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(successText, "Password updated") {
|
|
|
|
|
t.Errorf("success flash = %q", successText)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- the new password actually signs in ----------------------------------
|
|
|
|
|
var dashURL string
|
|
|
|
|
if err := chromedp.Run(bctx,
|
|
|
|
|
chromedp.WaitVisible(`#password`, chromedp.ByQuery),
|
|
|
|
|
chromedp.SendKeys(`#username`, username, chromedp.ByQuery),
|
|
|
|
|
chromedp.SendKeys(`#password`, newPassword, chromedp.ByQuery),
|
|
|
|
|
chromedp.Submit(`#password`, chromedp.ByQuery),
|
|
|
|
|
// Sign-in redirects auth host → handoff → app host.
|
|
|
|
|
waitForLocation(srv.appURL),
|
|
|
|
|
chromedp.Location(&dashURL),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("sign in with the new password: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(dashURL, srv.appURL) {
|
|
|
|
|
t.Errorf("after signing in the browser is at %q, want the app host %q", dashURL, srv.appURL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The old password must be dead. Checked through the real form rather than the
|
|
|
|
|
// store, since that's what an attacker holding it would use.
|
|
|
|
|
var loginError string
|
|
|
|
|
if err := chromedp.Run(bctx,
|
|
|
|
|
network.ClearBrowserCookies(),
|
|
|
|
|
chromedp.Navigate(srv.authURL+"/login"),
|
|
|
|
|
chromedp.WaitVisible(`#password`, chromedp.ByQuery),
|
|
|
|
|
chromedp.SendKeys(`#username`, username, chromedp.ByQuery),
|
|
|
|
|
chromedp.SendKeys(`#password`, oldPassword, chromedp.ByQuery),
|
|
|
|
|
chromedp.Submit(`#password`, chromedp.ByQuery),
|
|
|
|
|
chromedp.WaitVisible(`.alert-danger`, chromedp.ByQuery),
|
|
|
|
|
chromedp.Text(`.alert-danger`, &loginError, chromedp.ByQuery),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("old password check: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(loginError, "Invalid") {
|
|
|
|
|
t.Errorf("signing in with the old password gave %q, want it rejected", loginError)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch.assertClean(t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestE2ERemoveEmailConfirmDialog covers the destructive control the HTTP tests can't:
|
|
|
|
|
// removal is gated behind ui.js's window.confirm, so the markup being right proves
|
|
|
|
|
// nothing on its own — an unanswered dialog silently blocks the whole page.
|
|
|
|
|
func TestE2ERemoveEmailConfirmDialog(t *testing.T) {
|
|
|
|
|
srv := newE2EServer(t)
|
|
|
|
|
user, cookie := srv.login(t, "remove-email-user")
|
|
|
|
|
|
|
|
|
|
bctx, cancel, watch := startBrowser(t)
|
|
|
|
|
defer cancel()
|
|
|
|
|
acceptDialogs(bctx)
|
|
|
|
|
|
|
|
|
|
const address = "removeme@example.test"
|
|
|
|
|
if err := chromedp.Run(bctx,
|
|
|
|
|
network.Enable(),
|
|
|
|
|
cdplog.Enable(),
|
|
|
|
|
page.Enable(),
|
|
|
|
|
setSessionCookie(cookie),
|
|
|
|
|
chromedp.Navigate(srv.authURL+"/account"),
|
|
|
|
|
chromedp.WaitVisible(`#email`, chromedp.ByQuery),
|
|
|
|
|
chromedp.SendKeys(`#email`, address, chromedp.ByQuery),
|
|
|
|
|
chromedp.Click(`[data-testid="email-save"]`, chromedp.ByQuery),
|
|
|
|
|
chromedp.WaitVisible(`[data-testid="email-remove"]`, chromedp.ByQuery),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("add address: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stored2, err := srv.store.GetUserByID(srv.ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("get user: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if stored2.Email == nil {
|
|
|
|
|
t.Fatal("precondition: address wasn't saved")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Click Remove and accept the confirm dialog.
|
|
|
|
|
if err := chromedp.Run(bctx,
|
|
|
|
|
chromedp.Click(`[data-testid="email-remove"]`, chromedp.ByQuery),
|
|
|
|
|
// The address row disappearing is the only unambiguous signal: a success flash
|
|
|
|
|
// from the save is still on the page, so waiting on one would pass even if the
|
|
|
|
|
// confirm dialog had blocked the click entirely — which is the exact failure
|
|
|
|
|
// this test exists to catch.
|
|
|
|
|
chromedp.WaitNotPresent(`[data-testid="email-remove"]`, chromedp.ByQuery),
|
|
|
|
|
); err != nil {
|
|
|
|
|
t.Fatalf("remove address: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
after, err := srv.store.GetUserByID(srv.ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("get user: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if after.Email != nil {
|
|
|
|
|
t.Errorf("Email = %v after confirming removal, want nil", after.Email)
|
|
|
|
|
}
|
|
|
|
|
watch.assertClean(t)
|
|
|
|
|
}
|