test(e2e): convert the auth specs onto page objects (#137) #150

Merged
bermudalamb merged 1 commits from feature/137-convert-auth-specs into main 2026-08-23 17:36:31 -05:00
9 changed files with 317 additions and 269 deletions
+8
View File
@@ -133,6 +133,14 @@ export default tseslint.config(
// Page objects hold locators built in the constructor and never
// reassigned. Flagging them as mutable props does not apply to a class.
'sonarjs/prefer-read-only-props': 'off',
// Requesting a Playwright fixture IS using it — destructuring `customer`
// is what makes the account exist, whether or not the body then reads the
// address. The linter cannot see that side effect and reports every such
// fixture as an unused variable. Left on, the alternative is a `void
// customer;` line in each test, which is noise standing in for a comment.
// Variables are still checked; only parameters are exempt.
'@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }],
},
}
);
+113 -117
View File
@@ -1,56 +1,27 @@
import { test, expect, Page } from './fixtures';
const PASSWORD = 'supersecret123';
function uniqueEmail(): string {
return `playwright-${Date.now()}-${Math.floor(Math.random() * 10000)}@example.com`;
}
// Registering closes the auth modal and returns the customer to the page behind
// it — the storefront, for a direct visit to /register — rather than navigating
// to /account. So the header, not the URL, is what proves the session exists.
//
// The wait is generous because this is a bcrypt round-trip rather than a render,
// and the suite's workers all register at once.
async function register(page: Page, email: string) {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
}
// Log out lives inside the account view, which is a modal over the storefront.
async function openAccount(page: Page) {
await page.goto('/account');
await expect(page.getByRole('dialog', { name: 'My Account' })).toBeVisible();
}
import { test, expect, PASSWORD, uniqueEmail } from './fixtures';
test.describe('Customer accounts', () => {
// Both names are required from anyone new so every email has a first name to
// greet with (#106). The server refuses without them; this is the form
// refusing first, so nobody gets a round trip to find out.
test('will not submit a registration without both names', async ({ page }) => {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
test('will not submit a registration without both names', async ({ authModal, header }) => {
await authModal.gotoRegister();
await authModal.fillRegistration({ email: uniqueEmail(), password: PASSWORD });
await authModal.submitRegistration();
await expect(page.getByText('First name is required')).toBeVisible();
await expect(page.getByText('Last name is required')).toBeVisible();
await expect(authModal.registerDialog.getByText('First name is required')).toBeVisible();
await expect(authModal.registerDialog.getByText('Last name is required')).toBeVisible();
// Still on the form rather than signed in.
await expect(page.getByRole('button', { name: 'My Account' })).toHaveCount(0);
await expect(header.myAccountButton).toHaveCount(0);
});
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
await page.goto('/register');
await expect(page.getByRole('checkbox')).not.toBeChecked();
test('marketing consent checkbox is unchecked by default', async ({ authModal }) => {
await authModal.gotoRegister();
await expect(authModal.marketingConsent).not.toBeChecked();
});
test('the consent label is the exact wording the server records', async ({ page }) => {
await page.goto('/register');
test('the consent label is the exact wording the server records', async ({ authModal }) => {
await authModal.gotoRegister();
// The stored consent text is kept verbatim so the record says what the
// customer actually saw. Three different wordings were in circulation
@@ -58,83 +29,95 @@ test.describe('Customer accounts', () => {
// prompt, and none of them matched what was stored.
const consent =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
await expect(page.getByRole('dialog', { name: 'Create an account' })).toContainText(consent);
await expect(authModal.registerDialog).toContainText(consent);
});
test('registering signs the customer in and returns them where they were', async ({ page }) => {
// Drives the form rather than taking the `customer` fixture: this test is
// about registering, so the thing under test has to be the thing exercised.
test('registering signs the customer in and returns them where they were', async ({
page,
authModal,
header,
accountModal
}) => {
const email = uniqueEmail();
await register(page, email);
await authModal.gotoRegister();
await authModal.fillRegistration({
email,
password: PASSWORD,
firstName: 'Test',
lastName: 'Customer'
});
await authModal.submitRegistration();
await header.waitForSignedIn();
// Back on the storefront, signed in — not moved to the account page.
await expect(page).toHaveURL(/\/$/);
await openAccount(page);
await expect(page.getByText(email)).toBeVisible();
await accountModal.open();
await expect(accountModal.emailText(email)).toBeVisible();
});
test('rejects login with the wrong password', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await openAccount(page);
await page.getByRole('button', { name: 'Log out' }).click();
test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill('wrong-password');
// Scoped to the modal: the storefront rendered behind it has a "Log in"
// button of its own, which is what opened this one.
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
await authModal.gotoLogIn();
await authModal.logIn(customer.email, 'wrong-password');
await expect(page.getByText('invalid email or password')).toBeVisible();
});
test('logging out returns to the home page and resets the header', async ({ page }) => {
await register(page, uniqueEmail());
await openAccount(page);
await page.getByRole('button', { name: 'Log out' }).click();
test('logging out returns to the home page and resets the header', async ({
page,
customer,
accountModal,
header
}) => {
await accountModal.openAndLogOut();
// A server round-trip followed by re-rendering the storefront behind the
// modal, so the 5s default is too tight when workers run concurrently.
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
await expect(header.logInButton).toBeVisible();
await expect(header.signUpButton).toBeVisible();
await expect(header.myAccountButton).toBeHidden();
});
test('the logged-out header survives a reload', async ({ page }) => {
await register(page, uniqueEmail());
await openAccount(page);
await page.getByRole('button', { name: 'Log out' }).click();
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
test('the logged-out header survives a reload', async ({ page, customer, accountModal, header }) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
// Proves the server session was actually destroyed, rather than the header
// merely being repainted from stale client state.
await page.reload();
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
await expect(header.logInButton).toBeVisible();
await expect(header.myAccountButton).toBeHidden();
});
test('logging out does not leave the account page on the back stack', async ({ page }) => {
await register(page, uniqueEmail());
await openAccount(page);
await page.getByRole('button', { name: 'Log out' }).click();
test('logging out does not leave the account page on the back stack', async ({
page,
customer,
accountModal
}) => {
await accountModal.openAndLogOut();
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
await page.goBack();
await expect(page).not.toHaveURL(/\/account/);
});
test('a failed logout says so instead of appearing to succeed', async ({ page }) => {
await register(page, uniqueEmail());
await openAccount(page);
test('a failed logout says so instead of appearing to succeed', async ({
page,
customer,
accountModal
}) => {
await accountModal.open();
await page.route('**/api/customers/logout', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
);
await page.getByRole('button', { name: 'Log out' }).click();
await accountModal.logOut();
// The session cookie is still valid, so pretending to be logged out would
// silently log the customer back in on their next reload.
@@ -144,69 +127,82 @@ test.describe('Customer accounts', () => {
});
test.describe('Auth routes are not dead ends', () => {
test('opening Log in from the header closes back to where browsing left off', async ({ page }) => {
test('opening Log in from the header closes back to where browsing left off', async ({
page,
header,
authModal
}) => {
await page.goto('/?max_price=50000');
await page.getByRole('button', { name: 'Log in' }).click();
await header.logInButton.click();
const modal = page.getByRole('dialog', { name: 'Log in' });
await expect(modal).toBeVisible();
await expect(authModal.logInDialog).toBeVisible();
await expect(page).toHaveURL(/\/login/);
await modal.getByRole('button', { name: 'Close' }).click();
await authModal.closeButton.click();
await expect(modal).toBeHidden();
await expect(authModal.logInDialog).toBeHidden();
await expect(page).toHaveURL(/max_price=50000/);
});
test('a direct visit opens over the storefront rather than a blank page', async ({ page }) => {
await page.goto('/login');
test('a direct visit opens over the storefront rather than a blank page', async ({
authModal,
header
}) => {
await authModal.gotoLogIn();
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
await expect(authModal.logInDialog).toBeVisible();
await expect(header.siteTitle).toBeVisible();
});
test('switching between sign in and sign up keeps one history entry', async ({ page }) => {
test('switching between sign in and sign up keeps one history entry', async ({
page,
header,
authModal
}) => {
await page.goto('/?max_price=50000');
await page.getByRole('button', { name: 'Log in' }).click();
await header.logInButton.click();
await page.getByRole('tab', { name: 'Create Account' }).click();
await authModal.createAccountTab.click();
await expect(page).toHaveURL(/\/register/);
await page.getByRole('tab', { name: 'Log In' }).click();
await authModal.logInTab.click();
await expect(page).toHaveURL(/\/login/);
// Back returns to browsing rather than walking through each tab visited.
await page.goBack();
await expect(page).toHaveURL(/max_price=50000/);
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeHidden();
await expect(authModal.logInDialog).toBeHidden();
});
test('signing in from the header returns to the page behind, signed in', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await page.goto('/account');
await page.getByRole('button', { name: 'Log out' }).click();
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
test('signing in from the header returns to the page behind, signed in', async ({
page,
customer,
accountModal,
header,
authModal
}) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await page.goto('/?max_price=50000');
await page.getByRole('button', { name: 'Log in' }).click();
const modal = page.getByRole('dialog', { name: 'Log in' });
await modal.getByRole('textbox', { name: 'Email' }).fill(email);
await modal.getByLabel('Password').fill(PASSWORD);
await modal.getByRole('button', { name: 'Log in' }).click();
await header.logInButton.click();
await authModal.logIn(customer.email, customer.password);
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
await header.waitForSignedIn();
await expect(page).toHaveURL(/max_price=50000/);
});
test('reaching password recovery from the login form keeps a way back', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'Forgot password?' }).click();
test('reaching password recovery from the login form keeps a way back', async ({
page,
authModal,
passwordReset
}) => {
await authModal.gotoLogIn();
await authModal.forgotPasswordButton.click();
const modal = page.getByRole('dialog', { name: 'Reset your password' });
await expect(modal).toBeVisible();
await expect(passwordReset.requestDialog).toBeVisible();
await expect(page).toHaveURL(/\/forgot-password/);
await modal.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
await passwordReset.signInButton.click();
await expect(authModal.logInDialog).toBeVisible();
});
});
+5
View File
@@ -7,6 +7,7 @@ import { AuthModal } from './pages/AuthModal';
import { AccountModal } from './pages/AccountModal';
import { StorefrontPage } from './pages/StorefrontPage';
import { AdminPage } from './pages/AdminPage';
import { PasswordResetPages } from './pages/PasswordResetPages';
import { uniqueEmail } from './support/api';
// Re-exported so specs can import everything from here — expect, Page,
@@ -38,6 +39,7 @@ interface Pages {
accountModal: AccountModal;
storefront: StorefrontPage;
admin: AdminPage;
passwordReset: PasswordResetPages;
}
interface Data {
@@ -88,6 +90,9 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
admin: async ({ page }, use) => {
await use(new AdminPage(page));
},
passwordReset: async ({ page }, use) => {
await use(new PasswordResetPages(page));
},
adminApi: async ({ playwright, baseURL }, use) => {
const context = await playwright.request.newContext({ baseURL });
+16
View File
@@ -13,6 +13,7 @@ export class AccountModal {
readonly logOutButton: Locator;
readonly orderHistoryButton: Locator;
readonly closeButton: Locator;
readonly resendVerificationButton: Locator;
readonly firstName: Locator;
readonly lastName: Locator;
@@ -30,6 +31,7 @@ export class AccountModal {
this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' });
this.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' });
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' });
this.firstName = this.dialog.getByLabel('First name', { exact: true });
this.lastName = this.dialog.getByLabel('Last name', { exact: true });
@@ -59,6 +61,20 @@ export class AccountModal {
await this.logOutButton.click();
}
/**
* Opens the account view and logs out, which is the only route to signing out
* — there is no header control for it.
*
* Does not wait for the result. Several tests assert different things about
* what logging out does: the URL it lands on, the header it leaves behind, a
* failure it reports. Waiting here would make one of those the action's
* contract and quietly weaken the others.
*/
async openAndLogOut(): Promise<void> {
await this.open();
await this.logOut();
}
/** The address the account view shows, which is how a test knows whose it is. */
emailText(email: string): Locator {
return this.dialog.getByText(email);
+24
View File
@@ -23,6 +23,9 @@ export class AuthModal {
readonly password: Locator;
readonly marketingConsent: Locator;
readonly createAccountButton: Locator;
readonly createAccountTab: Locator;
readonly logInTab: Locator;
readonly forgotPasswordButton: Locator;
constructor(private readonly page: Page) {
this.registerDialog = page.getByRole('dialog', { name: 'Create an account' });
@@ -33,6 +36,9 @@ export class AuthModal {
this.password = page.getByLabel('Password');
this.marketingConsent = page.getByRole('checkbox');
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
this.createAccountTab = page.getByRole('tab', { name: 'Create Account' });
this.logInTab = page.getByRole('tab', { name: 'Log In' });
this.forgotPasswordButton = page.getByRole('button', { name: 'Forgot password?' });
}
/** The submit button inside the log in dialog, not the one that opened it. */
@@ -40,6 +46,24 @@ export class AuthModal {
return this.logInDialog.getByRole('button', { name: 'Log in' });
}
/** Closes whichever of the two dialogs is open. */
get closeButton(): Locator {
return this.page.getByRole('dialog').getByRole('button', { name: 'Close' });
}
/**
* Signs in through the modal, wherever it was opened from.
*
* Scoped to the dialog throughout: the storefront behind it has its own
* "Log in" button — the one that opened this — and an unscoped fill or click
* matches both.
*/
async logIn(email: string, password: string): Promise<void> {
await this.logInDialog.getByRole('textbox', { name: 'Email' }).fill(email);
await this.logInDialog.getByLabel('Password').fill(password);
await this.submitLogInButton.click();
}
async gotoRegister(): Promise<void> {
await this.page.goto('/register');
}
+2
View File
@@ -12,6 +12,7 @@ export class Header {
readonly siteTitle: Locator;
readonly myAccountButton: Locator;
readonly logInButton: Locator;
readonly signUpButton: Locator;
readonly cartButton: Locator;
readonly themeToggle: Locator;
@@ -19,6 +20,7 @@ export class Header {
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
this.myAccountButton = page.getByRole('button', { name: 'My Account' });
this.logInButton = page.getByRole('button', { name: 'Log in' });
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
this.cartButton = page.getByRole('button', { name: /Cart/ });
this.themeToggle = page.getByRole('switch');
}
@@ -0,0 +1,57 @@
import { Locator, Page, expect } from '@playwright/test';
/**
* The two halves of password recovery: asking for a link, and using one.
*
* They are one object because they are one flow and a test usually crosses
* between them. Requesting is a modal reached from the login form; setting the
* new password is a route the customer arrives at from their email, with no
* page behind it to return to.
*/
export class PasswordResetPages {
readonly requestDialog: Locator;
readonly email: Locator;
readonly sendResetLinkButton: Locator;
readonly signInButton: Locator;
readonly newPassword: Locator;
readonly confirmNewPassword: Locator;
readonly setNewPasswordButton: Locator;
constructor(private readonly page: Page) {
this.requestDialog = page.getByRole('dialog', { name: 'Reset your password' });
this.email = page.getByRole('textbox', { name: 'Email' });
this.sendResetLinkButton = page.getByRole('button', { name: 'Send reset link' });
this.signInButton = this.requestDialog.getByRole('button', { name: 'Sign in' });
this.newPassword = page.getByLabel('New password', { exact: true });
this.confirmNewPassword = page.getByLabel('Confirm new password');
this.setNewPasswordButton = page.getByRole('button', { name: 'Set new password' });
}
async gotoRequest(): Promise<void> {
await this.page.goto('/forgot-password');
}
/**
* Opens the reset form, with or without a token.
*
* Omitting it is a real case rather than a degenerate one: a link that lost
* its token has to explain itself instead of failing on submit.
*/
async gotoReset(token?: string): Promise<void> {
await this.page.goto(token ? `/reset-password?token=${token}` : '/reset-password');
}
async setNewPassword(password: string, confirmation = password): Promise<void> {
await this.newPassword.fill(password);
await this.confirmNewPassword.fill(confirmation);
await this.setNewPasswordButton.click();
}
async requestLinkFor(email: string): Promise<void> {
await this.email.fill(email);
await this.sendResetLinkButton.click();
await expect(this.page.getByText('Check your email')).toBeVisible();
}
}
+74 -116
View File
@@ -1,163 +1,121 @@
import { test, expect, Page } from './fixtures';
import { Client } from 'pg';
import { test, expect } from './fixtures';
import { readPasswordResetToken } from './support/db';
const PASSWORD = 'supersecret123';
const NEW_PASSWORD = 'a-brand-new-password';
const uniqueEmail = () => `reset-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
async function register(page: Page, email: string) {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
// Registering now closes the auth modal and returns to the page behind it, so
// the header rather than the URL is what proves the session exists. The wait
// is generous because this is a bcrypt round-trip rather than a render.
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
}
// Log out lives inside the account view, which is a modal over the storefront.
async function logout(page: Page) {
await page.goto('/account');
await page.getByRole('dialog', { name: 'My Account' })
.getByRole('button', { name: 'Log out' }).click();
// A server round-trip followed by re-rendering the storefront behind the
// modal, so the 5s default is too tight when workers run concurrently.
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
}
test.describe('Password reset', () => {
test('the login page offers a way to recover a forgotten password', async ({ page }) => {
await page.goto('/login');
// Recovery is now reached from the login modal rather than a link on a
// page, and its title is the modal's rather than a heading.
await page.getByRole('button', { name: 'Forgot password?' }).click();
test('the login page offers a way to recover a forgotten password', async ({
page,
authModal,
passwordReset
}) => {
await authModal.gotoLogIn();
// Recovery is reached from the login modal rather than a link on a page,
// and its title is the modal's rather than a heading.
await authModal.forgotPasswordButton.click();
await expect(page).toHaveURL(/\/forgot-password/);
await expect(page.getByRole('dialog', { name: 'Reset your password' })).toBeVisible();
await expect(passwordReset.requestDialog).toBeVisible();
});
test('requesting a reset confirms without revealing whether the account exists', async ({ page }) => {
await page.goto('/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).fill('definitely-nobody@example.com');
await page.getByRole('button', { name: 'Send reset link' }).click();
test('requesting a reset confirms without revealing whether the account exists', async ({
page,
passwordReset
}) => {
await passwordReset.gotoRequest();
await passwordReset.requestLinkFor('definitely-nobody@example.com');
// Identical wording either way; a differing message would make this an
// account-enumeration oracle.
await expect(page.getByText('Check your email')).toBeVisible();
await expect(page.getByText(/If an account exists/)).toBeVisible();
});
test('a reset link with no token explains itself instead of failing on submit', async ({ page }) => {
await page.goto('/reset-password');
test('a reset link with no token explains itself instead of failing on submit', async ({
page,
passwordReset
}) => {
await passwordReset.gotoReset();
await expect(page.getByText('This link is incomplete')).toBeVisible();
await expect(page.getByRole('button', { name: 'Set new password' })).toHaveCount(0);
await expect(passwordReset.setNewPasswordButton).toHaveCount(0);
});
test('rejects a mismatched confirmation before contacting the server', async ({ page }) => {
await page.goto('/reset-password?token=whatever');
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Confirm new password').fill('something-else-entirely');
await page.getByRole('button', { name: 'Set new password' }).click();
test('rejects a mismatched confirmation before contacting the server', async ({
page,
passwordReset
}) => {
await passwordReset.gotoReset('whatever');
await passwordReset.setNewPassword(NEW_PASSWORD, 'something-else-entirely');
await expect(page.getByText('The passwords do not match')).toBeVisible();
});
test('reports an invalid token rather than appearing to succeed', async ({ page }) => {
await page.goto('/reset-password?token=not-a-real-token');
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
await page.getByRole('button', { name: 'Set new password' }).click();
test('reports an invalid token rather than appearing to succeed', async ({
page,
passwordReset
}) => {
await passwordReset.gotoReset('not-a-real-token');
await passwordReset.setNewPassword(NEW_PASSWORD);
await expect(page.getByText('invalid or expired token')).toBeVisible();
await expect(page).toHaveURL(/\/reset-password/);
});
test('a customer can reset their password and sign in with the new one', async ({ page, request }) => {
const email = uniqueEmail();
await register(page, email);
await logout(page);
test('a customer can reset their password and sign in with the new one', async ({
request,
customer,
accountModal,
header,
authModal,
passwordReset
}) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
// The reset link arrives by email, which the tests can't read. Request the
// reset through the real endpoint, then read the issued token the way the
// customer's mail client would deliver it.
const requested = await request.post('/api/customers/request-password-reset', { data: { email } });
const requested = await request.post('/api/customers/request-password-reset', {
data: { email: customer.email }
});
expect(requested.ok()).toBeTruthy();
const token = await readResetToken(email);
await page.goto(`/reset-password?token=${token}`);
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
await page.getByRole('button', { name: 'Set new password' }).click();
await passwordReset.gotoReset(await readPasswordResetToken(customer.email));
await passwordReset.setNewPassword(NEW_PASSWORD);
// The reset signs them in and closes back to the storefront — the link came
// from an email, so there is no page behind it to return to.
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
await page.goto('/account');
await expect(page.getByText(email)).toBeVisible();
await header.waitForSignedIn();
await accountModal.open();
await expect(accountModal.emailText(customer.email)).toBeVisible();
// And the new password actually works on a fresh sign-in.
await logout(page);
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill(NEW_PASSWORD);
// Scoped to the modal: the storefront rendered behind it has a "Log in"
// button of its own, which is what opened this one.
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
await accountModal.logOut();
await expect(header.logInButton).toBeVisible();
await authModal.gotoLogIn();
await authModal.logIn(customer.email, NEW_PASSWORD);
await header.waitForSignedIn();
});
test('the old password stops working after a reset', async ({ page, request }) => {
const email = uniqueEmail();
await register(page, email);
await logout(page);
test('the old password stops working after a reset', async ({
page,
request,
customer,
accountModal,
header,
authModal
}) => {
await accountModal.openAndLogOut();
await expect(header.logInButton).toBeVisible();
await request.post('/api/customers/request-password-reset', { data: { email } });
const token = await readResetToken(email);
await request.post('/api/customers/request-password-reset', { data: { email: customer.email } });
const token = await readPasswordResetToken(customer.email);
await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } });
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill(PASSWORD);
// Scoped to the modal: the storefront rendered behind it has a "Log in"
// button of its own, which is what opened this one.
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
await authModal.gotoLogIn();
await authModal.logIn(customer.email, customer.password);
await expect(page.getByText('invalid email or password')).toBeVisible();
await expect(header.myAccountButton).toHaveCount(0);
});
});
// The token is only ever delivered by email, which these tests cannot read.
//
// It is read straight from the database rather than through a helper endpoint:
// an endpoint that returns a password-reset token for an arbitrary address is
// account takeover for every customer if it is ever reachable, and an
// environment gate is a thin thing to stand between that and production. Doing
// it here keeps the capability entirely inside the test process.
async function readResetToken(email: string): Promise<string> {
const client = new Client({
host: process.env.TEST_PGHOST || 'localhost',
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
user: process.env.TEST_PGUSER || 'redefined_test',
password: process.env.TEST_PGPASSWORD || 'redefined_test',
database: process.env.TEST_PGDATABASE || 'redefined_test'
});
await client.connect();
try {
const { rows } = await client.query(
`SELECT t.token FROM customer_tokens t
JOIN customers c ON c.id = t.customer_id
WHERE c.email = $1 AND t.kind = 'password_reset'
ORDER BY t.created_at DESC LIMIT 1`,
[email]
);
if (!rows.length) throw new Error(`no password_reset token issued for ${email}`);
return rows[0].token as string;
} finally {
await client.end();
}
}
+18 -36
View File
@@ -1,41 +1,20 @@
import { test, expect, Page } from './fixtures';
const PASSWORD = 'supersecret123';
const uniqueEmail = () => `resend-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
// The generous wait matches the other account specs: registration is a bcrypt
// round-trip rather than a render, and runs past Playwright's 5s default when
// the suite's workers all register at once.
async function registerCustomer(page: Page): Promise<string> {
const email = uniqueEmail();
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
return email;
}
const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' });
import { test, expect } from './fixtures';
test.describe('Resending your verification email', () => {
test('the account page offers it while the address is unverified', async ({ page }) => {
await registerCustomer(page);
await page.goto('/account');
test('the account page offers it while the address is unverified', async ({
customer,
accountModal
}) => {
await accountModal.open();
const modal = accountModal(page);
await expect(modal.getByText('Email not verified')).toBeVisible();
await expect(modal.getByRole('button', { name: 'Send it again' })).toBeVisible();
await expect(accountModal.dialog.getByText('Email not verified')).toBeVisible();
await expect(accountModal.resendVerificationButton).toBeVisible();
});
test('confirms when it has sent', async ({ page }) => {
await registerCustomer(page);
await page.goto('/account');
test('confirms when it has sent', async ({ page, customer, accountModal }) => {
await accountModal.open();
await accountModal(page).getByRole('button', { name: 'Send it again' }).click();
await accountModal.resendVerificationButton.click();
await expect(page.getByText('Check your inbox, and your spam folder.')).toBeVisible();
});
@@ -43,11 +22,14 @@ test.describe('Resending your verification email', () => {
// The message the customer gets on the fourth attempt is the point of the
// limiter's copy: it says the mail probably did send and where to look,
// rather than only that a limit exists.
test('says something useful once the allowance runs out', async ({ page }) => {
await registerCustomer(page);
await page.goto('/account');
test('says something useful once the allowance runs out', async ({
page,
customer,
accountModal
}) => {
await accountModal.open();
const resend = accountModal(page).getByRole('button', { name: 'Send it again' });
const resend = accountModal.resendVerificationButton;
for (let i = 0; i < 3; i++) {
await resend.click();
await expect(resend).toBeEnabled();