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>
208 lines
7.8 KiB
TypeScript
Executable File
208 lines
7.8 KiB
TypeScript
Executable File
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();
|
|
});
|
|
|
|
describe('POST /api/customers/register', () => {
|
|
// Both names are required from anyone new, so the greeting in every email has
|
|
// something to use. Refused individually rather than as one "name required",
|
|
// so a form that filled one and not the other is told which. See #106.
|
|
it('refuses a registration with no first name', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({
|
|
email: 'nofirst@example.com',
|
|
password: 'supersecret123',
|
|
lastName: 'Customer'
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('first name is required');
|
|
});
|
|
|
|
it('refuses a registration with no last name', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({
|
|
email: 'nolast@example.com',
|
|
password: 'supersecret123',
|
|
firstName: 'Test'
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('last name is required');
|
|
});
|
|
|
|
it('refuses names that are only whitespace', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({
|
|
email: 'blank@example.com',
|
|
password: 'supersecret123',
|
|
firstName: ' ',
|
|
lastName: 'Customer'
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('first name is required');
|
|
});
|
|
|
|
it('stores both names, trimmed, and returns them', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({
|
|
email: 'named@example.com',
|
|
password: 'supersecret123',
|
|
firstName: ' Thom ',
|
|
lastName: ' Lamb '
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.first_name).toBe('Thom');
|
|
expect(res.body.last_name).toBe('Lamb');
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT first_name, last_name FROM customers WHERE email = $1`,
|
|
['named@example.com']
|
|
);
|
|
expect(rows[0]).toEqual({ first_name: 'Thom', last_name: 'Lamb' });
|
|
});
|
|
|
|
// The account is the customer's own, but the shape returned to them should
|
|
// not quietly grow — a password hash or a token appearing here is the kind of
|
|
// thing that goes unnoticed.
|
|
it('does not return anything beyond the public customer shape', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({
|
|
email: 'shape@example.com',
|
|
password: 'supersecret123',
|
|
firstName: 'Test',
|
|
lastName: 'Customer'
|
|
});
|
|
|
|
expect(Object.keys(res.body).sort()).toEqual([
|
|
'analytics_consent', 'created_at', 'email', 'email_verified', 'favorite_alerts',
|
|
// Whether, never what. Added in #344 so the account page can offer to set
|
|
// a first password rather than to change one that does not exist; the
|
|
// hash itself must never appear in this list.
|
|
'first_name', 'has_password', 'id', 'last_name', 'marketing_consent'
|
|
]);
|
|
});
|
|
|
|
it('creates an account with marketing consent unchecked by default', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'jane@example.com',
|
|
password: 'supersecret123'
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.email).toBe('jane@example.com');
|
|
expect(res.body.marketing_consent).toBe(false);
|
|
expect(res.headers['set-cookie']).toBeDefined();
|
|
});
|
|
|
|
it('respects an explicit marketing opt-in', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'opt-in@example.com',
|
|
password: 'supersecret123',
|
|
marketingConsent: true
|
|
});
|
|
expect(res.body.marketing_consent).toBe(true);
|
|
});
|
|
|
|
// Quebec's Law 25 s.8.1 requires profiling to be off until the person turns
|
|
// it on, so this is a compliance property rather than a default worth
|
|
// debating. Asserted end to end because the column default, the register
|
|
// route and the stored wording all have to agree for it to hold.
|
|
it('creates an account with analytics consent off by default', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'analytics-default@example.com',
|
|
password: 'supersecret123'
|
|
});
|
|
expect(res.body.analytics_consent).toBe(false);
|
|
});
|
|
|
|
// The two consents are separate purposes and must be separately refusable.
|
|
// Taking the emails must not opt anybody into being tracked — that bundling
|
|
// is what GDPR treats as invalid consent, and it is the mistake this branch
|
|
// made once before it was caught.
|
|
it('opting in to marketing alone does not opt in to analytics', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'marketing-only@example.com',
|
|
password: 'supersecret123',
|
|
marketingConsent: true
|
|
});
|
|
expect(res.body.marketing_consent).toBe(true);
|
|
expect(res.body.analytics_consent).toBe(false);
|
|
});
|
|
|
|
it('respects an explicit analytics opt-in, independently of marketing', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'analytics-only@example.com',
|
|
password: 'supersecret123',
|
|
analyticsConsent: true
|
|
});
|
|
expect(res.body.analytics_consent).toBe(true);
|
|
// Refusing the emails while accepting the tracking has to be possible too,
|
|
// or the consent is not granular in both directions.
|
|
expect(res.body.marketing_consent).toBe(false);
|
|
});
|
|
|
|
it('rejects a duplicate email', async () => {
|
|
await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'dupe@example.com',
|
|
password: 'supersecret123'
|
|
});
|
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'dupe@example.com',
|
|
password: 'anotherpassword'
|
|
});
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it('rejects a password under 8 characters', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'short@example.com',
|
|
password: '123'
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('rejects a malformed email', async () => {
|
|
const res = await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'not-an-email',
|
|
password: 'supersecret123'
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('session-gated routes', () => {
|
|
it('logs in and can access /me with the returned session cookie', async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'login-test@example.com',
|
|
password: 'supersecret123'
|
|
});
|
|
const me = await agent.get('/api/customers/me');
|
|
expect(me.status).toBe(200);
|
|
expect(me.body.email).toBe('login-test@example.com');
|
|
});
|
|
|
|
it('rejects /me with no session', async () => {
|
|
const res = await request(app).get('/api/customers/me');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects login with the wrong password', async () => {
|
|
await request(app).post('/api/customers/register').send({ firstName: 'Test', lastName: 'Customer',
|
|
email: 'wrongpass@example.com',
|
|
password: 'supersecret123'
|
|
});
|
|
const res = await request(app).post('/api/customers/login').send({
|
|
email: 'wrongpass@example.com',
|
|
password: 'incorrect-password'
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|