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'; const REASON = 'Named the last two items bought and the shipping address on file.'; async function register(email: string): Promise { const res = await request(app) .post('/api/customers/register') .send({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD }); expect(res.status).toBe(200); 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; } function moveTo(id: number, email: string, reason: string = REASON) { return request(app).put(`/api/admin/customers/${id}/email`).send({ email, reason }); } /** * #337. The third step of the only recovery route available to a customer who * has lost their mailbox — and, structurally, the same operation as an account * takeover. These tests are mostly about the second half of that sentence. */ describe('PUT /api/admin/customers/:id/email', () => { it('moves the account, and leaves the new address unverified', async () => { const id = await register('lost@example.com'); const res = await moveTo(id, 'Recovered@Example.com '); expect(res.status).toBe(200); expect(res.body.customer.email).toBe('recovered@example.com'); expect(res.body.previousEmail).toBe('lost@example.com'); // Nobody has demonstrated receiving mail at the new address. A customer // reading it out over the phone is not that, and it is the commonest way // this goes wrong harmlessly. expect(res.body.customer.email_verified).toBe(false); }); it('records the change with the reason, which is the point of the endpoint', async () => { const id = await register('recorded@example.com'); await moveTo(id, 'new@example.com'); const { rows } = await pool.query<{ previous_email: string; new_email: string; reason: string }>( `SELECT previous_email, new_email, reason FROM customer_email_changes WHERE customer_id = $1`, [id] ); // A hand edit to the database leaves nothing behind. This row is the only // thing that distinguishes a verified recovery from a takeover afterwards. expect(rows).toHaveLength(1); expect(rows[0]).toEqual({ previous_email: 'recorded@example.com', new_email: 'new@example.com', reason: REASON }); }); it('refuses without a stated reason, rather than recording an empty one', async () => { const id = await register('noreason@example.com'); const res = await moveTo(id, 'new@example.com', ''); expect(res.status).toBe(400); const { rows } = await pool.query<{ email: string }>(`SELECT email FROM customers WHERE id = $1`, [id]); // Refused entirely, not performed and left unexplained. expect(requireRow(rows, 'the unchanged customer').email).toBe('noreason@example.com'); }); it('refuses a reason too short to be one', async () => { const id = await register('terse@example.com'); // "ok" cannot tell a recovery from a takeover, which is the only thing the // field is for. const res = await moveTo(id, 'new@example.com', 'ok'); expect(res.status).toBe(400); }); it('signs the customer out everywhere', async () => { const id = await register('sessions@example.com'); const token = await createSession(id); const asCustomer = () => request(app).get('/api/customers/me').set('Cookie', `rd_session=${token}`); expect((await asCustomer()).status).toBe(200); await moveTo(id, 'new@example.com'); // Somebody the system cannot identify asked for this change. A session // surviving it is one the new owner cannot see and cannot revoke. expect((await asCustomer()).status).toBe(401); }); it('removes every passkey, and says how many', async () => { const id = await register('keys@example.com'); await pool.query( `INSERT INTO customer_credentials (customer_id, credential_id, public_key, name) VALUES ($1, 'credential-a', 'not-a-real-key', 'Phone'), ($1, 'credential-b', 'not-a-real-key', 'Laptop')`, [id] ); const res = await moveTo(id, 'new@example.com'); expect(res.body.passkeysRemoved).toBe(2); const { rows } = await pool.query<{ n: number }>( `SELECT count(*)::int AS n FROM customer_credentials WHERE customer_id = $1`, [id] ); expect(requireRow(rows, 'a count of credentials').n).toBe(0); }); it('cancels reset links already sent to the old address', async () => { const id = await register('resetlink@example.com'); await request(app) .post('/api/customers/request-password-reset') .send({ email: 'resetlink@example.com' }); const before = await pool.query<{ n: number }>( `SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [id] ); expect(requireRow(before.rows, 'a count of reset tokens').n).toBe(1); await moveTo(id, 'new@example.com'); // That link is addressed to the mailbox this change is taking away. Leaving // it live would let whoever still reads it take the account straight back. const after = await pool.query<{ n: number }>( `SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [id] ); expect(requireRow(after.rows, 'a count of reset tokens').n).toBe(0); }); it('issues a verification link to the new address', async () => { const id = await register('verify@example.com'); await moveTo(id, 'new@example.com'); const { rows } = await pool.query<{ n: number }>( `SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`, [id] ); // Exactly one: registration issued a link to the old address, and issuing // this one has to supersede it, or a message in the mailbox being taken // away could still verify. expect(requireRow(rows, 'a count of verification tokens').n).toBe(1); }); it('refuses an address another account already uses', async () => { const id = await register('mover@example.com'); await register('occupied@example.com'); const res = await moveTo(id, 'occupied@example.com'); expect(res.status).toBe(409); }); it('refuses the address the account already has', async () => { const id = await register('same@example.com'); const res = await moveTo(id, 'same@example.com'); expect(res.status).toBe(400); }); it('refuses a malformed address', async () => { const id = await register('malformed@example.com'); const res = await moveTo(id, 'not-an-email'); expect(res.status).toBe(400); }); it('is a 404 for a customer who does not exist', async () => { const res = await moveTo(999999, 'new@example.com'); expect(res.status).toBe(404); }); it('leaves the customer able to sign in with their existing password', async () => { const id = await register('stillworks@example.com'); await moveTo(id, 'moved@example.com'); // The move is not a password reset. The customer knows their password — // what they lost was the mailbox — so demanding a new one would add a step // for no gain. const login = await request(app) .post('/api/customers/login') .send({ email: 'moved@example.com', password: PASSWORD }); expect(login.status).toBe(200); }); }); describe('GET /api/admin/customers/:id/email-changes', () => { it('lists what has been done to this account, newest first', async () => { const id = await register('history@example.com'); await moveTo(id, 'second@example.com', 'First recovery, verified against order history.'); await moveTo(id, 'third@example.com', 'Second recovery, verified against the shipping address.'); const res = await request(app).get(`/api/admin/customers/${id}/email-changes`); expect(res.status).toBe(200); expect(res.body).toHaveLength(2); // Newest first, because an account that has been moved twice is the one // worth looking at and the most recent move is the one in question. expect(res.body[0].new_email).toBe('third@example.com'); expect(res.body[1].new_email).toBe('second@example.com'); }); it('is empty for a customer whose address has never been moved', async () => { const id = await register('untouched@example.com'); const res = await request(app).get(`/api/admin/customers/${id}/email-changes`); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); });