mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 13:32:53 +00:00
Tests for account recovery
This commit is contained in:
1 parent
259c660927
commit
799b9d1d0c
5 files changed
+245
-6
No files matched your search
@@ -98,12 +98,12 @@
|
||||
</div>
|
||||
{{if not .EmailVerified}}
|
||||
<form method="post" action="/account/email/verify" class="m-0">
|
||||
<button type="submit" class="btn btn-sm">Resend</button>
|
||||
<button type="submit" class="btn btn-sm" data-testid="email-resend">Resend</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/account/email" class="m-0" data-confirm="Remove your email address? If you forget your password, it can't be reset without one.">
|
||||
<input type="hidden" name="remove" value="1">
|
||||
<button type="submit" class="btn btn-sm btn-ghost-danger">Remove</button>
|
||||
<button type="submit" class="btn btn-sm btn-ghost-danger" data-testid="email-remove">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@
|
||||
<input type="email" class="form-control" id="email" name="email" autocomplete="email" autocapitalize="none" maxlength="254" placeholder="you@example.com" required>
|
||||
</div>
|
||||
<div class="col-sm-auto">
|
||||
<button type="submit" class="btn btn-primary w-100">{{if .Email}}Save new address{{else}}Save address{{end}}</button>
|
||||
<button type="submit" class="btn btn-primary w-100" data-testid="email-save">{{if .Email}}Save new address{{else}}Save address{{end}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-hint mt-1">We'll send a confirmation link. A new address has to be confirmed before it can recover your account.</small>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
maxlength="254" placeholder="your-username or you@example.com" required>
|
||||
</div>
|
||||
<div class="form-footer mt-0">
|
||||
<button type="submit" class="btn btn-primary w-100">Send reset link</button>
|
||||
<button type="submit" class="btn btn-primary w-100" data-testid="forgot-submit">Send reset link</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
to a form we can't act on is worse than no link. */}}
|
||||
<label class="form-label" for="password">
|
||||
Password
|
||||
{{if .MailEnabled}}<span class="form-label-description"><a href="/forgot" tabindex="-1">Forgot password?</a></span>{{end}}
|
||||
{{if .MailEnabled}}<span class="form-label-description"><a href="/forgot" tabindex="-1" data-testid="forgot-link">Forgot password?</a></span>{{end}}
|
||||
</label>
|
||||
<div class="input-group input-group-flat">
|
||||
<input type="password" class="form-control" id="password" name="password" autocomplete="current-password" placeholder="Your password" required>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-footer mt-0">
|
||||
<button type="submit" class="btn btn-primary w-100">Set new password</button>
|
||||
<button type="submit" class="btn btn-primary w-100" data-testid="reset-submit">Set new password</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="text-secondary small mt-3 mb-0">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user