test(e2e): convert the auth specs onto page objects (#137)
First conversion batch: auth, password-reset, resend-verification. verify-email is left alone — it already used semantic locators and duplicated nothing, and rewriting it to prove a point would be churn. Four of the nine copies of "register a customer" go here. auth.spec.ts and password-reset.spec.ts each carried their own `register`, and resend-verification.spec.ts its own `registerCustomer`, all three re-explaining the same bcrypt wait and the same "the header, not the URL, proves the session exists" reasoning in slightly different words. Two also carried their own `logout`, and two their own `uniqueEmail` with different prefixes. Most of those tests were not about registering. They needed an account to exist so they could test logging out, resetting a password, or resending a verification email, and they paid for a bcrypt round-trip through the form to get one. Those now take the `customer` fixture, which registers through the API. The three tests that genuinely are about the registration form still drive it, because the thing under test has to be the thing exercised. The batch drops from 47s to 26s as a side effect, which is the cost of that round-trip made visible. password-reset.spec.ts loses its inline pg.Client. The reasoning for reading the database directly is unchanged and still right — an endpoint returning a reset token for an arbitrary address is account takeover if it is ever reachable — but it now lives in support/db.ts where it cannot be copied into the next spec wanting a shortcut. It also stops defaulting to port 55432, which is the integration suite's disposable Postgres rather than the database the app under test is connected to, and is Hyper-V-reserved on at least one machine here. Both tests in that file previously failed with a bare ECONNREFUSED unless TEST_PGPORT was set by hand; they now pass with no environment at all. New PasswordResetPages object covers both halves of recovery — requesting a link, and using one — because they are one flow and a test usually crosses between them. One lint decision worth recording. 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. The first attempt at appeasing it was a `void customer;` line per test, which is noise standing in for a comment — and sonarjs flags that too, so it traded one warning for another. `no-unused-vars` is now configured with `args: 'none'` for tests only, with the reason written next to it. Variables are still checked; only parameters are exempt. Verified: 26/26 in the converted batch, and 127 passed in the full suite with four failures — three in the known #116 flaky family, and resend-verification's rate-limit test, which passes 9/9 across three repeats in isolation and is timing-sensitive under parallel load rather than changed by this commit. Refs #137
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user