Files
redefined-designs/backend/tests/integration/passwordReset.integration.test.ts
T
bermudalambandClaude Opus 5 b287c07747
Tests / lint (pull_request) Successful in 1m38s
Tests / backend-unit (pull_request) Successful in 1m44s
Tests / frontend-e2e (pull_request) Failing after 9m50s
SonarQube Analysis / sonarqube (pull_request) Failing after 11m46s
feat: capture first and last name so emails can greet informally (#106)
Registration collected one optional Name, so every greeting had only a whole name to use: "Hi Thom Lamb," rather than "Hi Thom,". Both parts are now captured, and the cart reminder greets by first name.

Both are required of anyone new, refused individually rather than as a single "name is required" so a form that filled one and not the other is told which.

The columns are nullable even so, and that is deliberate. Marking them NOT NULL would mean backfilling legacy rows with empty strings, which asserts that every customer has a name — untrue of anyone who registered while the field was optional. The table records what is actually the case; the rule that new registrations must supply both lives in the route, where a missing field can produce a message naming it.

The backfill splits on the first space, and it is lossy in a way no version of this avoids. "Thom Lamb" becomes Thom and Lamb; "Mary Jane Smith" gets a last name of "Jane Smith"; names that are not two parts fare worse. It was chosen over leaving the columns empty because nothing currently lets a customer correct their own name — PUT /api/customers/me exists but no frontend calls it — so empty would have meant permanently unpersonalised for every existing customer. The migration says so, so nobody later reads backfilled values as data the customer supplied in that shape.

Verified against a seeded database rather than reasoned about, because this is the part that cannot be covered by the suite: migrations run in globalSetup before any test, and the old column is gone afterwards. Six representative rows through the real migration gave Thom/Lamb, Mary/"Jane Smith", Cher/null, "  Padded  Name  " trimmed to Padded/Name, and null and whitespace-only names left as null on both. The down migration rejoins the parts and returns all six to their original strings.

The old column is dropped rather than kept alongside, so there is one source of truth instead of two that drift.

The admin keeps receiving a single composed display name. It only ever shows one — the list cell and the drawer title — and never edits one, so giving it both parts plus the joining logic would be work for no reader.

Churn was the bulk of this: 14 backend registrations and 10 end-to-end registration forms. A first attempt at the backend fixtures also added names to login and password-reset payloads, which would still have passed since the server ignores unknown fields, but a login test implying login takes a name is a small lie; that was reverted and redone against register calls only.

Verified: 172 unit, 183 integration and 95 end-to-end passing, lint unchanged at 4 backend and 27 frontend warnings.

Not covered: the cart reminder itself, which runs from a cron and had no test before this either. The greeting change is a one-line substitution in that query.

Refs #106
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:23:13 -05:00

208 lines
8.2 KiB
TypeScript

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({ firstName: 'Test', lastName: 'Customer', email, password: PASSWORD });
expect(res.status).toBe(200);
return agent;
}
async function latestResetToken(email: string): Promise<string | undefined> {
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<string> {
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);
});
});