import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; import { verificationResendStore } from '../../src/rateLimit'; jest.mock('../../src/mailer', () => ({ sendMail: jest.fn().mockResolvedValue(undefined) })); import { sendMail } from '../../src/mailer'; const sentMail = sendMail as jest.MockedFunction; const PASSWORD = 'supersecret123'; beforeEach(async () => { await resetDb(); sentMail.mockClear(); // resetDb truncates with RESTART IDENTITY, so every test's first customer is // id 1 and the limiter — keyed on customer id, with a process-wide store — // hands them all the same bucket. Without this, three tests that each send // once leave the fourth starting at its limit. await verificationResendStore.resetAll?.(); }); afterAll(async () => { await pool.end(); await closeDb(); }); // Each test registers its own customer. That alone does NOT isolate the // allowance, which is what the beforeEach above is for: RESTART IDENTITY hands // every test the same customer id, so "a fresh customer" is a fresh row with a // recycled identity. This is the same class of leakage #62 and #84 recorded, // surviving a key that looked like it had solved it. async function register(email: string) { const agent = request.agent(app); const res = await agent .post('/api/customers/register') .send({ email, password: PASSWORD, firstName: 'Thom', lastName: 'Lamb' }); expect(res.status).toBe(200); sentMail.mockClear(); return agent; } const tokensFor = async (email: string) => { 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'`, [email] ); return rows.map(r => r.token as string); }; describe('resending your own verification email', () => { it('refuses an unauthenticated caller', async () => { const res = await request(app).post('/api/customers/resend-verification'); expect(res.status).toBe(401); expect(sentMail).not.toHaveBeenCalled(); }); it('sends to the address on the account', async () => { const email = 'resend1@example.com'; const agent = await register(email); const res = await agent.post('/api/customers/resend-verification'); expect(res.status).toBe(204); expect(sentMail).toHaveBeenCalledTimes(1); expect(String(sentMail.mock.calls[0]?.[0])).toBe(email); }); // The point of the whole thing. An un-superseded link means a message still // sitting in the inbox goes on working, which is the case the supersede in // issueVerificationEmail exists to prevent. it('mints a new token and invalidates the previous one', async () => { const email = 'resend2@example.com'; const agent = await register(email); const [before] = await tokensFor(email); expect(before).toBeDefined(); await agent.post('/api/customers/resend-verification'); const after = await tokensFor(email); expect(after).toHaveLength(1); expect(after[0]).not.toBe(before); // And the old link is genuinely dead, asserted through the endpoint that // would honour it rather than by counting rows. const stale = await request(app).post('/api/customers/verify-email').send({ token: before }); expect(stale.status).toBe(400); }); it('the new link verifies the address', async () => { const email = 'resend3@example.com'; const agent = await register(email); await agent.post('/api/customers/resend-verification'); const [token] = await tokensFor(email); const res = await request(app).post('/api/customers/verify-email').send({ token }); expect(res.status).toBe(200); const me = await agent.get('/api/customers/me'); expect(me.body.email_verified).toBe(true); }); it('refuses once the address is already verified', async () => { const email = 'resend4@example.com'; const agent = await register(email); const [token] = await tokensFor(email); await request(app).post('/api/customers/verify-email').send({ token }); sentMail.mockClear(); const res = await agent.post('/api/customers/resend-verification'); expect(res.status).toBe(400); expect(res.body.error).toBe('your email address is already verified'); expect(sentMail).not.toHaveBeenCalled(); }); // Three per hour. The fourth is refused, and the message says what actually // happened rather than only that a limit exists. it('stops after the allowance, with a message worth reading', async () => { const agent = await register('resend5@example.com'); for (let i = 0; i < 3; i++) { expect((await agent.post('/api/customers/resend-verification')).status).toBe(204); } const fourth = await agent.post('/api/customers/resend-verification'); expect(fourth.status).toBe(429); expect(String(fourth.body.error)).toContain('spam folder'); // Refused rather than merely reported: the fourth send must not have gone. expect(sentMail).toHaveBeenCalledTimes(3); }); // The allowance is per customer, not per address or per caller. Keying it any // more coarsely would let one customer spend everybody else's. it('one customer exhausting the allowance does not affect another', async () => { const first = await register('resend6@example.com'); for (let i = 0; i < 3; i++) await first.post('/api/customers/resend-verification'); expect((await first.post('/api/customers/resend-verification')).status).toBe(429); const second = await register('resend7@example.com'); expect((await second.post('/api/customers/resend-verification')).status).toBe(204); }); });