The last issue in the passkeys project, and the only one that adds no capability. It is the safety net, and it exists because this is the piece most likely to be skipped and most expensive to discover missing. A reset is the recovery path, and recovery has to be complete. The reset already deletes every session on the account, on the reasoning that a reset prompted by a compromise must not leave an intruder signed in for the remaining thirty days of their cookie. A passkey an intruder registered has no expiry at all. Leaving those behind would mean a customer can recover their password and still not have their account back. The obvious objection is that this hands whoever controls the mailbox a way to strip a customer's passkeys. It does, and it costs nothing: anyone who can complete a reset already controls the email address and therefore already controls the account. The passkeys were not protecting anything by that point. Anything in flight goes too. An intruder who pressed add a passkey moments before the reset could otherwise finish that ceremony afterwards and put a credential straight back onto the account the reset had just cleared. Changing a password deliberately does not do this, and the asymmetry is the point. A change requires the current password from someone already signed in, so nothing about it suggests a lockout or a compromise, and it already spares the current session for the same reason. A customer who suspects one particular device revokes that device by name from the account page, which is a better tool than deleting everything. A reset has no idea which credential is the problem, so it takes all of them. The customer is told twice. Before, in the reset email and on the reset form, unconditionally — that form has no session and is never told whether the account has passkeys, because answering that would make the reset page an oracle for it, so the wording has to read the same to someone who has none. After, with a count, and only when the count is not zero. That moment is the only one where the count can be reported: the rows are gone by the time anyone could go and look. A customer told two were removed who only remembers registering one has just learned something they could not otherwise find out. That notice is a panel that waits to be dismissed rather than a toast, because a toast dismisses itself and this is the message a customer needs to still be looking at while they decide what to do about it. The other question this issue asks — whether a reset ends a session established by a passkey — turns out to need no code, because #39 made both paths call one createSession. But "it falls out for free" is a claim, so there is now a test that establishes a session through that exact function and watches the reset end it. docs/ops/account-recovery.md records the whole policy, including the two answers that are not code. Losing an authenticator is not a lockout: the customer signs in with their password and revokes the lost credential themselves, which is why this change implements nothing for it. Losing the email address is a lockout, and there is deliberately no self-service route out — this shop holds no second proof of identity, and anything invented to fill that gap would be a weaker credential than the one it replaced. The manual route runs through the shop owner verifying against order history, and its third step, changing the address from the admin screen, does not exist yet. That is written down as a gap with its own notification and audit questions rather than smuggled in here. Verified: backend tsc clean for src and tests, 521 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for, so CI is what proves those. Closes #42 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
import { test, expect } from './fixtures';
|
|
import { giveCustomerAPasskey, readPasswordResetToken } from './support/db';
|
|
|
|
const NEW_PASSWORD = 'a-brand-new-password';
|
|
|
|
test.describe('Password reset', () => {
|
|
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(passwordReset.requestDialog).toBeVisible();
|
|
});
|
|
|
|
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(/If an account exists/)).toBeVisible();
|
|
});
|
|
|
|
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(passwordReset.setNewPasswordButton).toHaveCount(0);
|
|
});
|
|
|
|
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,
|
|
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 ({
|
|
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: customer.email }
|
|
});
|
|
expect(requested.ok()).toBeTruthy();
|
|
|
|
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 header.waitForSignedIn();
|
|
await accountModal.open();
|
|
await expect(accountModal.emailText(customer.email)).toBeVisible();
|
|
|
|
// And the new password actually works on a fresh sign-in.
|
|
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,
|
|
customer,
|
|
accountModal,
|
|
header,
|
|
authModal
|
|
}) => {
|
|
await accountModal.openAndLogOut();
|
|
await expect(header.logInButton).toBeVisible();
|
|
|
|
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 authModal.gotoLogIn();
|
|
await authModal.logIn(customer.email, customer.password);
|
|
|
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
|
await expect(header.myAccountButton).toHaveCount(0);
|
|
});
|
|
|
|
// #42. A reset removes every passkey on the account, which is the one thing
|
|
// it does that a customer cannot undo and might have chosen differently
|
|
// about, so both halves of telling them are asserted here.
|
|
test('the reset form says passkeys will be removed before the customer commits', async ({
|
|
passwordReset,
|
|
page
|
|
}) => {
|
|
await passwordReset.gotoReset('any-token-will-do');
|
|
|
|
// Said unconditionally, and it has to be: this form has no session and is
|
|
// never told whether the account has passkeys, because answering that would
|
|
// make the reset page an oracle for it. So the warning shows on a token
|
|
// that was never issued, exactly as it would on a real one.
|
|
await expect(page.getByText(/passkeys saved on this account will be removed/i)).toBeVisible();
|
|
});
|
|
|
|
test('a reset that removed a passkey says so, and waits to be acknowledged', async ({
|
|
page,
|
|
request,
|
|
customer,
|
|
accountModal,
|
|
header,
|
|
passwordReset
|
|
}) => {
|
|
await giveCustomerAPasskey(customer.email);
|
|
await accountModal.openAndLogOut();
|
|
await expect(header.logInButton).toBeVisible();
|
|
|
|
await request.post('/api/customers/request-password-reset', { data: { email: customer.email } });
|
|
await passwordReset.gotoReset(await readPasswordResetToken(customer.email));
|
|
await passwordReset.setNewPassword(NEW_PASSWORD);
|
|
|
|
// A toast would be the wrong shape: it dismisses itself, and a customer
|
|
// told that a credential they do not remember registering has just been
|
|
// deleted needs to still be looking at that when they decide what to do.
|
|
await expect(page.getByText('Your saved passkey was removed')).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Done' }).click();
|
|
|
|
// The reset still succeeded — this was a notice, not a step that failed.
|
|
await header.waitForSignedIn();
|
|
});
|
|
});
|