Files
redefined-designs/frontend/tests/e2e/password-reset.spec.ts
T
bermudalamb 5ebb366074 feat(auth): open sign-in and registration as modals over the page behind (#50)
/login, /register and /forgot-password rendered bare cards with no site header. They linked to each other and nowhere else, so a customer who clicked Log in from the storefront and changed their mind had no way back except the browser's back button. All four auth routes are now modals over the page the customer was already on, reusing the backdrop-location arrangement from #51: a direct visit or a link from an email opens over the storefront, so closing always lands somewhere real. They remain real routes, because /reset-password links to /login and customers may have bookmarks.

Signing in or registering now returns the customer to the page behind, signed in, rather than moving them to /account. Someone who signs in while browsing wants to carry on browsing, and this is already how the cart and favorites prompts behave when they resume an interrupted action.

The larger half of this is removing the duplication. Signing in existed twice — as these routes and again inside the prompt shown when a signed-out visitor adds to the cart or favorites something — and the two had already drifted. There were three different wordings of the marketing consent in circulation: the register page's, a shorter one in the prompt, and the string the server actually stores. The server keeps that text verbatim so the consent record says what the customer saw, which none of the three did. Both callers now render one shared AuthForm whose checkbox is the exact string the server records, and a test asserts that wording so it cannot drift again silently.

Steps within the auth flow replace rather than push, so switching between tabs or stepping to password recovery leaves the whole detour as a single history entry and closing returns to where it started instead of walking back through every tab that was looked at.

The privacy policy link opens in a new tab: following it in place would discard a part-filled signup form, and /privacy still has no way back of its own until #52.

Test changes follow from the destination change rather than being incidental. Nineteen assertions across seven specs waited for /account after signing in; they now assert the header shows a signed-in customer, which is the condition actually being waited for. Modal submits are scoped to their dialog, because the storefront behind now offers a Log in button of its own and an unscoped locator matched both. Assertions that follow a server round-trip were given a realistic timeout — the 5s default is too tight for a bcrypt hash plus re-rendering the storefront behind the modal.

Verified with 83 end-to-end tests, all passing, and type checking clean. No backend changes.

Closes #50
2026-08-18 17:24:28 -05:00

162 lines
7.6 KiB
TypeScript

import { test, expect, Page } from '@playwright/test';
import { Client } from 'pg';
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.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();
await expect(page).toHaveURL(/\/forgot-password/);
await expect(page.getByRole('dialog', { name: 'Reset your password' })).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();
// 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');
await expect(page.getByText('This link is incomplete')).toBeVisible();
await expect(page.getByRole('button', { name: 'Set new password' })).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();
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();
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);
// 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 } });
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();
// 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();
// 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 });
});
test('the old password stops working after a reset', async ({ page, request }) => {
const email = uniqueEmail();
await register(page, email);
await logout(page);
await request.post('/api/customers/request-password-reset', { data: { email } });
const token = await readResetToken(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 expect(page.getByText('invalid email or password')).toBeVisible();
});
});
// 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();
}
}