Files
redefined-designs/backend/tests/integration/passwordlessAccounts.integration.test.ts
T
synAdminandClaude Opus 5 dcb3c7c91b
Linting / lint (pull_request) Successful in 2m55s
SonarQube Analysis / sonarqube (pull_request) Failing after 27m31s
feat(auth): the routes that assumed every customer has a password (#344)
The first accounts in this project's history have no password. Several things that were true stop being true, and one check written a month ago finally becomes reachable.

Setting a first password and changing an existing one stay one route. A customer who signed up with Google cannot supply a value that was never set, so asking for one is a dead end; what authorises the change is the session they are already holding, which is what authorises every other setting on the account page. Two routes would be two places to get the guard wrong, and the one that would be forgotten is whichever is not on the path exercised by hand. The branch reads the stored hash rather than anything the caller sends, so a request cannot talk its way into the first-password case by omitting a field — there is a test for exactly that.

Changing the email address is refused instead, and the asymmetry is the point. Setting a first password changes a credential the customer already controls. Changing the address changes where recovery goes, and whoever holds the new one can reset the password and own the account outright. That is why the route has always demanded more than a live session, and dropping the demand for the accounts that cannot meet it would remove the protection from exactly the ones that need it. The message says the real thing and names the way out, rather than claiming a password was wrong when there is none.

Login is left exactly as it was. Answering "this account has no password" to a submitted address would turn the form into an oracle for which customers use Google, so it keeps the single refusal and the account page is where a signed-in customer learns what they have. Two tests pin that, including the one where both the supplied password and the stored hash are empty — the combination most tempting to call a match, and the one that would let anyone sign in as any Google-only customer.

Deletion needed nothing, because it never asked for a password. That corrects what #332 recorded, and there is now a test so it stays true.

The passkey lockout guard runs for the first time. It was written in #40 against the condition rather than the schema and has been unreachable ever since, because password_hash was NOT NULL. Three tests exercise it now: refused when it is the only way in, allowed when a second passkey remains, allowed once a password has been set.

Two things about password reset were worth checking rather than assuming, and both turn out to be right as they stand. A customer who never had a password can still reset one, which is what somebody reaching for "forgot password" was asking for. And a reset still removes every passkey, per #42, because nothing about that path identifies who asked. What it does not do is sever the Google identity, and that asymmetry is deliberate: a passkey is a credential this shop issued and can revoke, while a Google identity is one Google holds, and cutting it would leave the customer unable to use the button they signed up with for no gain — whoever completed the reset controls the mailbox either way.

The account page is told whether a password exists, and nothing more. Offering to change a password to somebody who has never had one is a dead end; saying nothing leaves them unable to see a credential they are entitled to manage. So the panel is titled for what it does for this customer, the current-password field is absent rather than disabled, and the confirmation says they can now sign in with it as well as with Google.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for.

Closes #344

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:06:49 -05:00

364 lines
14 KiB
TypeScript

import request from 'supertest';
import bcrypt from 'bcryptjs';
import app from '../../src/app';
import { pool, requireRow } from '../../src/db';
import { createSession } from '../../src/customerSession';
import { PASSWORD_HASH_ROUNDS } from '../../src/passwordHashing';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
const PASSWORD = 'supersecret123';
/**
* A customer who signed up with Google: no password at all (#344).
*
* Inserted rather than driven through the OAuth flow, because what these tests
* are about is the state, not how it was reached. The flow that produces it has
* its own suite.
*/
async function passwordlessCustomer(email: string): Promise<number> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
VALUES ($1, NULL, 'Test', 'Customer', true, $2) RETURNING id`,
[email, `unsub-${email}`]
);
const id = requireRow(rows, 'the passwordless customer').id;
await pool.query(
`INSERT INTO customer_identities (customer_id, provider, provider_sub) VALUES ($1, 'google', $2)`,
[id, `sub-${email}`]
);
return id;
}
async function customerWithPassword(email: string): Promise<number> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
VALUES ($1, $2, 'Test', 'Customer', true, $3) RETURNING id`,
[email, await bcrypt.hash(PASSWORD, PASSWORD_HASH_ROUNDS), `unsub-${email}`]
);
return requireRow(rows, 'the customer with a password').id;
}
async function sessionFor(customerId: number): Promise<string> {
return `rd_session=${await createSession(customerId)}`;
}
async function storedHash(customerId: number): Promise<string | null> {
const { rows } = await pool.query<{ password_hash: string | null }>(
`SELECT password_hash FROM customers WHERE id = $1`,
[customerId]
);
return requireRow(rows, 'the customer').password_hash;
}
describe('an account with no password', () => {
describe('setting a first one', () => {
it('takes no current password, because there is none to give', async () => {
const id = await passwordlessCustomer('first@example.com');
const session = await sessionFor(id);
const res = await request(app)
.post('/api/customers/change-password')
.set('Cookie', session)
.send({ newPassword: 'a-brand-new-password' });
// Asking for a value that was never set is a dead end. The session they
// are already holding is what authorises this, exactly as it authorises
// every other setting on the account page.
expect(res.status).toBe(204);
expect(await storedHash(id)).not.toBeNull();
});
it('lets them sign in with it afterwards', async () => {
const id = await passwordlessCustomer('cansignin@example.com');
await request(app)
.post('/api/customers/change-password')
.set('Cookie', await sessionFor(id))
.send({ newPassword: 'a-brand-new-password' });
const login = await request(app)
.post('/api/customers/login')
.send({ email: 'cansignin@example.com', password: 'a-brand-new-password' });
expect(login.status).toBe(200);
});
it('enforces the same minimum length as registration', async () => {
const id = await passwordlessCustomer('short@example.com');
const res = await request(app)
.post('/api/customers/change-password')
.set('Cookie', await sessionFor(id))
.send({ newPassword: 'short' });
expect(res.status).toBe(400);
expect(await storedHash(id)).toBeNull();
});
it('still demands the current one from an account that has a password', async () => {
// The branch is on the stored hash, never on what the caller sends, so a
// request cannot talk its way into the first-password case by omitting a
// field.
const id = await customerWithPassword('haspassword@example.com');
const res = await request(app)
.post('/api/customers/change-password')
.set('Cookie', await sessionFor(id))
.send({ newPassword: 'a-brand-new-password' });
expect(res.status).toBe(401);
});
});
describe('signing in with a password', () => {
it('is refused exactly as a wrong password is', async () => {
await passwordlessCustomer('oracle@example.com');
const res = await request(app)
.post('/api/customers/login')
.send({ email: 'oracle@example.com', password: 'anything-at-all' });
// Answering "this account has no password" would turn the login form into
// an oracle for which customers use Google. One refusal for every cause,
// and the account page is where a signed-in customer learns what they
// have.
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid email or password');
});
it('is refused for a blank password too, rather than matching an absent hash', async () => {
await passwordlessCustomer('blank@example.com');
const res = await request(app)
.post('/api/customers/login')
.send({ email: 'blank@example.com', password: '' });
// Both sides missing is the combination most tempting to call a match,
// and calling it one would let anyone sign in as any Google-only customer.
expect(res.status).toBe(401);
});
});
describe('changing the email address', () => {
it('is refused, and says why rather than claiming a password was wrong', async () => {
const id = await passwordlessCustomer('moving@example.com');
const res = await request(app)
.put('/api/customers/me/email')
.set('Cookie', await sessionFor(id))
.send({ email: 'somewhere-else@example.com' });
// Changing the address is a change to where recovery goes: whoever holds
// the new one can reset the password and own the account outright. That is
// why this route has always demanded more than a live session, and
// dropping the demand for accounts that cannot meet it would remove the
// protection from exactly the ones that need it.
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/no password/);
});
it('works once they have set one', async () => {
const id = await passwordlessCustomer('thenmoving@example.com');
const session = await sessionFor(id);
await request(app)
.post('/api/customers/change-password')
.set('Cookie', session)
.send({ newPassword: 'a-brand-new-password' });
const res = await request(app)
.put('/api/customers/me/email')
.set('Cookie', session)
.send({ email: 'moved@example.com', currentPassword: 'a-brand-new-password' });
expect(res.status).toBe(200);
});
});
describe('deleting the account', () => {
it('works, because deletion never asked for a password', async () => {
const id = await passwordlessCustomer('deleting@example.com');
const res = await request(app).delete('/api/customers/me').set('Cookie', await sessionFor(id));
expect(res.status).toBe(204);
const { rows } = await pool.query<{ n: number }>(
`SELECT count(*)::int AS n FROM customers WHERE id = $1`,
[id]
);
expect(requireRow(rows, 'a count of customers').n).toBe(0);
});
});
describe('the passkey lockout guard, which becomes reachable here', () => {
async function givePasskey(customerId: number, credentialId: string): Promise<number> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
VALUES ($1, $2, 'not-a-real-key', 'Phone') RETURNING id`,
[customerId, credentialId]
);
return requireRow(rows, 'the credential just created').id;
}
it('refuses to remove the last way into an account with no password', async () => {
// Written in #40 against the condition rather than the schema, and
// unreachable until now because password_hash was NOT NULL. This is the
// first test that actually exercises it.
const id = await passwordlessCustomer('lastway@example.com');
const credentialId = await givePasskey(id, 'only-credential');
const res = await request(app)
.delete(`/api/customers/me/passkeys/${credentialId}`)
.set('Cookie', await sessionFor(id));
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/only way you can sign in/);
});
it('allows it when a second passkey remains', async () => {
const id = await passwordlessCustomer('twokeys@example.com');
const first = await givePasskey(id, 'credential-one');
await givePasskey(id, 'credential-two');
const res = await request(app)
.delete(`/api/customers/me/passkeys/${first}`)
.set('Cookie', await sessionFor(id));
expect(res.status).toBe(204);
});
it('allows it once a password has been set', async () => {
const id = await passwordlessCustomer('nowhaspassword@example.com');
const credentialId = await givePasskey(id, 'credential-with-password');
const session = await sessionFor(id);
await request(app)
.post('/api/customers/change-password')
.set('Cookie', session)
.send({ newPassword: 'a-brand-new-password' });
const res = await request(app)
.delete(`/api/customers/me/passkeys/${credentialId}`)
.set('Cookie', session);
expect(res.status).toBe(204);
});
});
describe('resetting a password that was never set', () => {
it('gives them one, which is a reasonable answer rather than an error', async () => {
await passwordlessCustomer('resetting@example.com');
await request(app)
.post('/api/customers/request-password-reset')
.send({ email: 'resetting@example.com' });
const { rows } = await pool.query<{ token: string }>(
`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'`,
['resetting@example.com']
);
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
// The reset path sets a hash and does not care whether one was there
// before. A customer who reaches for "forgot password" without ever
// having had one gets a working password, which is what they were asking
// for.
expect(res.status).toBe(200);
});
it('removes their passkeys, which is worth knowing rather than assuming', async () => {
// #42 made a reset remove every passkey, on the reasoning that recovery
// has to be complete. That still holds here: nothing about this path
// identifies who asked, and a Google-only customer resetting a password
// they never had is not obviously in a better position than one who did.
const id = await passwordlessCustomer('resetkeys@example.com');
await pool.query(
`INSERT INTO customer_credentials (customer_id, credential_id, public_key, name)
VALUES ($1, 'reset-credential', 'not-a-real-key', 'Phone')`,
[id]
);
await request(app)
.post('/api/customers/request-password-reset')
.send({ email: 'resetkeys@example.com' });
const { rows } = await pool.query<{ token: string }>(
`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'`,
['resetkeys@example.com']
);
const res = await request(app)
.post('/api/customers/reset-password')
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
expect(res.body.passkeysRemoved).toBe(1);
});
it('leaves the Google identity attached, so they keep both ways in', async () => {
const id = await passwordlessCustomer('keepsgoogle@example.com');
await request(app)
.post('/api/customers/request-password-reset')
.send({ email: 'keepsgoogle@example.com' });
const { rows } = await pool.query<{ token: string }>(
`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'`,
['keepsgoogle@example.com']
);
await request(app)
.post('/api/customers/reset-password')
.send({ token: requireRow(rows, 'the reset token').token, password: 'a-brand-new-password' });
// Deliberately not removed alongside the passkeys. A passkey is a
// credential this shop issued and can revoke; a Google identity is one
// Google holds, and severing it would leave the customer unable to use
// the button they signed up with for no gain — whoever completed the
// reset controls the mailbox either way.
const { rows: identities } = await pool.query<{ n: number }>(
`SELECT count(*)::int AS n FROM customer_identities WHERE customer_id = $1`,
[id]
);
expect(requireRow(identities, 'a count of identities').n).toBe(1);
});
});
describe('what the account page is told', () => {
it('reports has_password false for a Google-only customer', async () => {
const id = await passwordlessCustomer('told@example.com');
const res = await request(app).get('/api/customers/me').set('Cookie', await sessionFor(id));
expect(res.body.has_password).toBe(false);
});
it('reports it true once one is set', async () => {
const id = await passwordlessCustomer('nowtrue@example.com');
const session = await sessionFor(id);
await request(app)
.post('/api/customers/change-password')
.set('Cookie', session)
.send({ newPassword: 'a-brand-new-password' });
const res = await request(app).get('/api/customers/me').set('Cookie', session);
expect(res.body.has_password).toBe(true);
});
it('never returns the hash itself', async () => {
const id = await customerWithPassword('nohash@example.com');
const res = await request(app).get('/api/customers/me').set('Cookie', await sessionFor(id));
expect(res.body.password_hash).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('$2');
});
});
});