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 { 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(); } }