import request from 'supertest'; import app from '../../src/app'; import { pool, requireRow } from '../../src/db'; import { createSession } from '../../src/customerSession'; import { resetDb, closeDb } from './setup/testDb'; beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); const PASSWORD = 'supersecret123'; async function register(email: string) { const agent = request.agent(app); const res = await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD }); expect(res.status).toBe(200); return agent; } async function latestResetToken(email: string): Promise { const { rows } = await pool.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] ); return rows[0]?.token; } describe('POST /api/customers/request-password-reset', () => { it('issues a reset token for a known address', async () => { await register('known@example.com'); const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'known@example.com' }); expect(res.status).toBe(200); expect(await latestResetToken('known@example.com')).toBeTruthy(); }); it('reports the same success for an unknown address, and issues nothing', async () => { const res = await request(app) .post('/api/customers/request-password-reset') .send({ email: 'nobody@example.com' }); // Differing responses would turn this endpoint into an oracle for which // addresses have accounts. expect(res.status).toBe(200); const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM customer_tokens WHERE kind = 'password_reset'`); expect(rows[0].n).toBe(0); }); it('matches the address case-insensitively, as login does', async () => { await register('mixed@example.com'); await request(app).post('/api/customers/request-password-reset').send({ email: 'MiXeD@Example.com ' }); expect(await latestResetToken('mixed@example.com')).toBeTruthy(); }); it('invalidates an earlier token when a new one is requested', async () => { await register('twice@example.com'); await request(app).post('/api/customers/request-password-reset').send({ email: 'twice@example.com' }); const first = await latestResetToken('twice@example.com'); await request(app).post('/api/customers/request-password-reset').send({ email: 'twice@example.com' }); const second = await latestResetToken('twice@example.com'); expect(second).not.toBe(first); const stale = await request(app) .post('/api/customers/reset-password') .send({ token: first, password: 'brandnewpassword' }); expect(stale.status).toBe(400); }); it('rejects a malformed email without pretending to have sent anything', async () => { const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'not-an-email' }); expect(res.status).toBe(400); }); it('rate limits repeated requests for the same address', async () => { await register('flood@example.com'); const statuses: number[] = []; for (let i = 0; i < 8; i++) { const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'flood@example.com' }); statuses.push(res.status); } // Without a limit this endpoint will send unlimited mail to any address. expect(statuses).toContain(429); }); }); describe('POST /api/customers/reset-password', () => { async function requestReset(email: string): Promise { await request(app).post('/api/customers/request-password-reset').send({ email }); const token = await latestResetToken(email); expect(token).toBeTruthy(); return token as string; } it('sets a new password and rejects the old one', async () => { await register('change@example.com'); const token = await requestReset('change@example.com'); const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); expect(res.status).toBe(200); const oldLogin = await request(app).post('/api/customers/login').send({ email: 'change@example.com', password: PASSWORD }); expect(oldLogin.status).toBe(401); const newLogin = await request(app) .post('/api/customers/login') .send({ email: 'change@example.com', password: 'a-brand-new-password' }); expect(newLogin.status).toBe(200); }); it('signs the customer in on success', async () => { await register('signedin@example.com'); const token = await requestReset('signedin@example.com'); const agent = request.agent(app); const res = await agent.post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); expect(res.status).toBe(200); const me = await agent.get('/api/customers/me'); expect(me.status).toBe(200); expect(me.body.email).toBe('signedin@example.com'); }); it('terminates sessions established before the reset', async () => { const oldSession = await register('evict@example.com'); expect((await oldSession.get('/api/customers/me')).status).toBe(200); const token = await requestReset('evict@example.com'); await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); // A reset prompted by a compromise has to evict the attacker; leaving a // 30-day cookie alive would defeat the point. expect((await oldSession.get('/api/customers/me')).status).toBe(401); }); it('marks the email verified, since the customer received mail at it', async () => { await register('unverified@example.com'); const token = await requestReset('unverified@example.com'); await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); const { rows } = await pool.query(`SELECT email_verified FROM customers WHERE email = $1`, ['unverified@example.com']); expect(rows[0].email_verified).toBe(true); }); it('consumes the token so it cannot be replayed', async () => { await register('replay@example.com'); const token = await requestReset('replay@example.com'); await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); const second = await request(app).post('/api/customers/reset-password').send({ token, password: 'another-password' }); expect(second.status).toBe(400); }); it('rejects an expired token', async () => { await register('expired@example.com'); const token = await requestReset('expired@example.com'); await pool.query(`UPDATE customer_tokens SET expires_at = now() - interval '1 minute' WHERE token = $1`, [token]); const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); expect(res.status).toBe(400); }); it('refuses a verify_email token, so one kind cannot stand in for another', async () => { await register('crosskind@example.com'); const { rows } = await pool.query( `SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id WHERE c.email = $1 AND t.kind = 'verify_email'`, ['crosskind@example.com'] ); expect(rows[0].token).toBeTruthy(); const res = await request(app) .post('/api/customers/reset-password') .send({ token: rows[0].token, password: 'a-brand-new-password' }); expect(res.status).toBe(400); }); it('rejects an unknown token', async () => { const res = await request(app) .post('/api/customers/reset-password') .send({ token: 'nonsense', password: 'a-brand-new-password' }); expect(res.status).toBe(400); }); it('enforces the same minimum password length as registration', async () => { await register('short@example.com'); const token = await requestReset('short@example.com'); const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'short' }); expect(res.status).toBe(400); // A rejected attempt must not burn the token. const retry = await request(app).post('/api/customers/reset-password').send({ token, password: 'long-enough-password' }); expect(retry.status).toBe(200); }); // #42. A reset is the recovery path, so it has to leave the account with no // way in that the customer did not just establish. Sessions were already // covered above; a passkey outlives a session without bound. describe('and the passkeys on the account', () => { async function customerId(email: string): Promise { const { rows } = await pool.query<{ id: number }>(`SELECT id FROM customers WHERE email = $1`, [email]); return requireRow(rows, 'the customer this test just registered').id; } // Registering one for real needs an authenticator, which no test has. The // row is what the reset acts on, so the row is what these insert. async function giveAPasskey(id: number, credentialId: string): Promise { await pool.query( `INSERT INTO customer_credentials (customer_id, credential_id, public_key, name) VALUES ($1, $2, 'not-a-real-key', 'Test key')`, [id, credentialId] ); } async function passkeyCount(id: number): Promise { const { rows } = await pool.query<{ n: number }>( `SELECT count(*)::int AS n FROM customer_credentials WHERE customer_id = $1`, [id] ); return requireRow(rows, 'a count of credentials').n; } it('removes every passkey, so one an intruder registered does not survive it', async () => { await register('haskeys@example.com'); const id = await customerId('haskeys@example.com'); await giveAPasskey(id, 'credential-one'); await giveAPasskey(id, 'credential-two'); const token = await requestReset('haskeys@example.com'); const res = await request(app) .post('/api/customers/reset-password') .send({ token, password: 'a-brand-new-password' }); expect(res.status).toBe(200); expect(await passkeyCount(id)).toBe(0); }); it('says how many it removed, because nothing else can report it afterwards', async () => { await register('counted@example.com'); const id = await customerId('counted@example.com'); await giveAPasskey(id, 'credential-counted'); const token = await requestReset('counted@example.com'); const res = await request(app) .post('/api/customers/reset-password') .send({ token, password: 'a-brand-new-password' }); // The rows are gone by the time the customer could go and look, so a // reset that removed something and said nothing would hide exactly the // case worth knowing about. expect(res.body.passkeysRemoved).toBe(1); }); it('reports zero for a customer who never registered one', async () => { await register('nokeys@example.com'); const token = await requestReset('nokeys@example.com'); const res = await request(app) .post('/api/customers/reset-password') .send({ token, password: 'a-brand-new-password' }); // The notice on the reset form is shown on this number, so zero has to // mean zero rather than undefined. expect(res.status).toBe(200); expect(res.body.passkeysRemoved).toBe(0); }); it('leaves another customer’s passkeys alone', async () => { await register('mine@example.com'); await register('theirs@example.com'); const mine = await customerId('mine@example.com'); const theirs = await customerId('theirs@example.com'); await giveAPasskey(mine, 'credential-mine'); await giveAPasskey(theirs, 'credential-theirs'); const token = await requestReset('mine@example.com'); await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); expect(await passkeyCount(theirs)).toBe(1); }); it('clears a challenge in flight, so a registration cannot land after the reset', async () => { await register('inflight@example.com'); const id = await customerId('inflight@example.com'); await pool.query( `INSERT INTO webauthn_challenges (challenge, customer_id, kind, expires_at) VALUES ('challenge-in-flight', $1, 'registration', now() + interval '5 minutes')`, [id] ); const token = await requestReset('inflight@example.com'); await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); // Otherwise an intruder who pressed "add a passkey" moments earlier could // finish the ceremony afterwards and put a credential back on the account // the reset had just cleared. const { rows } = await pool.query<{ n: number }>( `SELECT count(*)::int AS n FROM webauthn_challenges WHERE customer_id = $1`, [id] ); expect(requireRow(rows, 'a count of challenges').n).toBe(0); }); it('terminates a session established by a passkey, not only one from a password', async () => { await register('passkeysession@example.com'); const id = await customerId('passkeysession@example.com'); // The call the passkey login route makes. Not an imitation of it — the // same function, so this asserts the shared session path rather than // asserting that two paths happen to agree today. const passkeySession = await createSession(id); const asPasskeyHolder = () => request(app).get('/api/customers/me').set('Cookie', `rd_session=${passkeySession}`); expect((await asPasskeyHolder()).status).toBe(200); const token = await requestReset('passkeysession@example.com'); await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' }); expect((await asPasskeyHolder()).status).toBe(401); }); }); });