import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; 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({ 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); }); });