Files
redefined-designs/frontend/tests/e2e/password-reset.spec.ts
T
bermudalambandClaude Opus 5 b287c07747
Tests / lint (pull_request) Successful in 1m38s
Tests / backend-unit (pull_request) Successful in 1m44s
Tests / frontend-e2e (pull_request) Failing after 9m50s
SonarQube Analysis / sonarqube (pull_request) Failing after 11m46s
feat: capture first and last name so emails can greet informally (#106)
Registration collected one optional Name, so every greeting had only a whole name to use: "Hi Thom Lamb," rather than "Hi Thom,". Both parts are now captured, and the cart reminder greets by first name.

Both are required of anyone new, refused individually rather than as a single "name is required" so a form that filled one and not the other is told which.

The columns are nullable even so, and that is deliberate. Marking them NOT NULL would mean backfilling legacy rows with empty strings, which asserts that every customer has a name — untrue of anyone who registered while the field was optional. The table records what is actually the case; the rule that new registrations must supply both lives in the route, where a missing field can produce a message naming it.

The backfill splits on the first space, and it is lossy in a way no version of this avoids. "Thom Lamb" becomes Thom and Lamb; "Mary Jane Smith" gets a last name of "Jane Smith"; names that are not two parts fare worse. It was chosen over leaving the columns empty because nothing currently lets a customer correct their own name — PUT /api/customers/me exists but no frontend calls it — so empty would have meant permanently unpersonalised for every existing customer. The migration says so, so nobody later reads backfilled values as data the customer supplied in that shape.

Verified against a seeded database rather than reasoned about, because this is the part that cannot be covered by the suite: migrations run in globalSetup before any test, and the old column is gone afterwards. Six representative rows through the real migration gave Thom/Lamb, Mary/"Jane Smith", Cher/null, "  Padded  Name  " trimmed to Padded/Name, and null and whitespace-only names left as null on both. The down migration rejoins the parts and returns all six to their original strings.

The old column is dropped rather than kept alongside, so there is one source of truth instead of two that drift.

The admin keeps receiving a single composed display name. It only ever shows one — the list cell and the drawer title — and never edits one, so giving it both parts plus the joining logic would be work for no reader.

Churn was the bulk of this: 14 backend registrations and 10 end-to-end registration forms. A first attempt at the backend fixtures also added names to login and password-reset payloads, which would still have passed since the server ignores unknown fields, but a login test implying login takes a name is a small lie; that was reverted and redone against register calls only.

Verified: 172 unit, 183 integration and 95 end-to-end passing, lint unchanged at 4 backend and 27 frontend warnings.

Not covered: the cart reminder itself, which runs from a cron and had no test before this either. The greeting change is a one-line substitution in that query.

Refs #106
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:23:13 -05:00

164 lines
7.7 KiB
TypeScript

import { test, expect, Page } from './fixtures';
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.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();
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();
}
}