Files
redefined-designs/frontend/tests/e2e/password-reset.spec.ts
T
bermudalambandClaude Opus 5 db7c61c89d
SonarQube Analysis / sonarqube (pull_request) Successful in 2m57s
Tests / backend-unit (pull_request) Successful in 53s
Tests / frontend-e2e (pull_request) Failing after 7m40s
Tests / backend-integration (pull_request) Failing after 3h14m41s
feat: customer password reset via email round-trip (#32)
Adds "Forgot password?" to the login page, a request page, and a reset
page reached by a one-hour, single-use token delivered by email. Reuses
customer_tokens with a new password_reset kind alongside verify_email.

The request endpoint always answers 200, whether or not the address has
an account, so it cannot be used to test addresses for membership. Note
/register still reveals existence through its 409 on a duplicate, so this
protection is currently partial; closing that is its own change.

Completing a reset deletes every session for that customer. A reset
prompted by a compromise has to evict the intruder, and leaving a 30-day
cookie alive would defeat the point. It also marks the address verified,
since receiving the mail is exactly what verification proves, and
supersedes any outstanding token so an older link in the inbox cannot be
resurrected.

Introduces the first rate limiting in the codebase, on the request
endpoint only. The limiter is keyed on caller *and* submitted address:
keying on IP alone would let one person lock out everyone behind the same
proxy, and everything arrives via Nginx Proxy Manager. Applying that same
limiter to the reset endpoint, which carries no address, collapsed every
caller into one shared bucket -- so that endpoint is deliberately
unlimited instead, protected by a 32-byte single-use token whose bcrypt
work only runs after the token matches.

The e2e tests read the issued token directly from Postgres rather than
through a test-support endpoint. An endpoint returning a reset token for
an arbitrary address is account takeover for every customer if it is ever
reachable, and an environment gate is thin protection against that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:36:19 -05:00

146 lines
6.3 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();
await expect(page).toHaveURL(/\/account/);
}
async function logout(page: Page) {
await page.getByRole('button', { name: 'Log out' }).click();
await expect(page).toHaveURL(/\/$/);
}
test.describe('Password reset', () => {
test('the login page offers a way to recover a forgotten password', async ({ page }) => {
await page.goto('/login');
await page.getByRole('link', { name: 'Forgot password?' }).click();
await expect(page).toHaveURL(/\/forgot-password/);
await expect(page.getByRole('heading', { 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, so they land on the account page.
await expect(page).toHaveURL(/\/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);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL(/\/account/);
});
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);
await page.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();
}
}