// Engagement Phase 1b — the change-and-verify flow, at the controller level. // // Every model call is monkeypatched, so no query runs. What is under test is the // DECISION-MAKING, and three properties in particular that no single unit of the // code enforces on its own: // // 1. Requesting a change never touches the live address. The account keeps // receiving password-reset mail at the address it had until a link proves the // new one. A regression here is silent, and only shows up when somebody // cannot recover their account. // 2. Confirming answers IDENTICALLY for every failure. Expired, already-used, // superseded, and "another account verified this address first" are one 404 // with one message. Any divergence turns the endpoint into an oracle for // which addresses hold accounts. // 3. Changing the address is re-authenticated, with the SSO carve-out. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, after, beforeEach, afterEach } = require('node:test') const assert = require('node:assert/strict') const account = require('../src/router/v1/auth/account.controller') const verify = require('../src/router/v1/auth/emailVerify.controller') const users = require('../src/model/users/users.model') const emailVerifications = require('../src/model/emailVerifications/emailVerifications.model') const activity = require('../src/model/activity/activity.model') const mailer = require('../src/utils/mailer') const db = require('../src/utils/db') after(() => db.close()) function mockRes() { return { statusCode: 200, body: null, status(c) { this.statusCode = c return this }, json(b) { this.body = b return this }, } } // Restore-on-teardown patching, keyed by owner+name so two modules may each carry // a function of the same name. const patched = [] function stub(obj, name, fn) { patched.push([obj, name, obj[name]]) obj[name] = fn } let sent let staged let promoted beforeEach(() => { sent = [] staged = [] promoted = [] stub(activity, 'log', async () => {}) stub(mailer, 'sendEmailVerification', async (args) => { sent.push(args) return { sent: true } }) stub(users, 'setPendingEmail', async (id, email) => { staged.push([id, email]) return 1 }) stub(users, 'clearPendingEmail', async () => 1) stub(users, 'promotePendingEmail', async (id, email) => { promoted.push([id, email]) return true }) stub(users, 'validatePassword', async (_u, pw) => pw === 'correct-horse') stub(emailVerifications, 'sendQuotaExhausted', async () => false) stub(emailVerifications, 'invalidatePendingForUser', async () => 0) stub(emailVerifications, 'create', async () => ({ id: 1, token: 'tok-abcdefgh' })) }) afterEach(() => { while (patched.length) { const [obj, name, fn] = patched.pop() obj[name] = fn } }) const reqFor = (body, user = {}) => ({ body, ip: '10.0.0.1', user: { id: 7, username: 'alice', ...user }, }) // ── 1. The live address is never touched by a request ────────────────────── test('requesting a change stages the address and leaves the live one alone', async () => { let updateCalled = false stub(users, 'update', async () => { updateCalled = true }) stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' })) const res = mockRes() await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'correct-horse' }), res) assert.equal(res.statusCode, 200) assert.deepEqual(staged, [[7, 'new@example.com']], 'the new address is STAGED') assert.equal(updateCalled, false, 'users.update must NOT be called - the live address stands') assert.equal(res.body.email_pending, 'new@example.com') assert.equal(sent.length, 1, 'a verification mail goes to the address being proved') assert.equal(sent[0].to, 'new@example.com') }) test('the verification link goes to the NEW address, never the old one', async () => { stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' })) const res = mockRes() await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'correct-horse' }), res) assert.equal(sent[0].to, 'new@example.com') assert.notEqual(sent[0].to, 'old@example.com') }) // ── 2. Re-authentication, with the SSO carve-out ─────────────────────────── test('a wrong current password is refused and stages nothing', async () => { stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' })) const res = mockRes() await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'wrong' }), res) assert.equal(res.statusCode, 400) assert.deepEqual(staged, [], 'nothing may be staged on a failed re-auth') assert.equal(sent.length, 0, 'and no mail may go out') }) test('an SSO-only account (no password hash) may change its address without one', async () => { stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null })) const res = mockRes() await account.changeEmail(reqFor({ email: 'first@example.com' }), res) assert.equal(res.statusCode, 200, 'the carve-out changePassword already makes, made here too') assert.deepEqual(staged, [[7, 'first@example.com']]) }) test('setting the same address again is refused', async () => { stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'Same@Example.com' })) const res = mockRes() // Compared case-folded, because the uniqueness index folds case: this IS the // same mailbox, and staging it would mail the user a link to prove what they // have already proved. await account.changeEmail(reqFor({ email: 'same@example.com', currentPassword: 'correct-horse' }), res) assert.equal(res.statusCode, 400) assert.deepEqual(staged, []) }) // ── 3. Confirming: every failure answers identically ─────────────────────── const INVALID = 'This confirmation link is invalid or has expired.' test('an unusable link 404s with the generic message', async () => { stub(emailVerifications, 'findValidByToken', async () => null) const res = mockRes() await verify.confirm({ params: { token: 'nope' }, ip: '1.2.3.4' }, res) assert.equal(res.statusCode, 404) assert.equal(res.body.message, INVALID) }) test('an address another account verified first answers the SAME 404', async () => { stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'taken@example.com' })) stub(emailVerifications, 'consume', async () => true) stub(users, 'promotePendingEmail', async () => { const err = new Error("Duplicate entry 'taken@example.com' for key 'uq_users_email_norm'") err.code = 'ER_DUP_ENTRY' err.errno = 1062 err.sqlMessage = "Duplicate entry 'taken@example.com' for key 'uq_users_email_norm'" throw err }) const res = mockRes() await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res) assert.equal(res.statusCode, 404, 'not a 409 - that would be an enumeration oracle') assert.equal(res.body.message, INVALID, 'byte-identical to an expired link') }) test('a superseded link answers the SAME 404', async () => { stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'stale@example.com' })) stub(emailVerifications, 'consume', async () => true) stub(users, 'promotePendingEmail', async () => false) // the guard rejected it const res = mockRes() await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res) assert.equal(res.statusCode, 404) assert.equal(res.body.message, INVALID) }) test('a link that lost the double-use race answers the SAME 404', async () => { stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'a@example.com' })) stub(emailVerifications, 'consume', async () => false) // someone else consumed it first const res = mockRes() await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res) assert.equal(res.statusCode, 404) assert.equal(res.body.message, INVALID) assert.deepEqual(promoted, [], 'and must not touch the account') }) test('a good link installs the address and issues no session', async () => { stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'good@example.com' })) stub(emailVerifications, 'consume', async () => true) const res = mockRes() await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res) assert.equal(res.statusCode, 200) assert.equal(res.body.ok, true) assert.deepEqual(promoted, [[7, 'good@example.com']]) // The response carries no token, cookie or user — proving control of a mailbox // is not proving control of an account. assert.equal(res.body.token, undefined) assert.equal(res.body.user, undefined) }) // ── 4. The send ceiling, and honest reporting when mail is off ───────────── test('the per-user send ceiling refuses with 429 and sends nothing', async () => { stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null })) stub(emailVerifications, 'sendQuotaExhausted', async () => true) const res = mockRes() await account.changeEmail(reqFor({ email: 'new@example.com' }), res) assert.equal(res.statusCode, 429) assert.equal(sent.length, 0) }) test('unconfigured mail is reported honestly, and the address stays staged', async () => { stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null })) stub(mailer, 'sendEmailVerification', async () => ({ sent: false, reason: 'NOT_CONFIGURED' })) const res = mockRes() await account.changeEmail(reqFor({ email: 'new@example.com' }), res) assert.equal(res.statusCode, 200) assert.equal(res.body.emailed, false) assert.equal(res.body.reason, 'NOT_CONFIGURED') assert.deepEqual(staged, [[7, 'new@example.com']], 'staged, so a later resend can work') }) test('resending with nothing pending is a 400, not a mail', async () => { stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'a@example.com', email_pending: null })) const res = mockRes() await account.resendEmailVerification(reqFor({}), res) assert.equal(res.statusCode, 400) assert.equal(sent.length, 0) }) test('resending re-sends to the pending address', async () => { stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'a@example.com', email_pending: 'p@example.com', })) const res = mockRes() await account.resendEmailVerification(reqFor({}), res) assert.equal(res.statusCode, 200) assert.equal(sent.length, 1) assert.equal(sent[0].to, 'p@example.com') }) test('cancelling clears the pending address AND retires its outstanding links', async () => { let cleared = false let retired = false stub(users, 'clearPendingEmail', async () => { cleared = true return 1 }) stub(emailVerifications, 'invalidatePendingForUser', async () => { retired = true return 1 }) const res = mockRes() await account.cancelEmailChange(reqFor({}), res) assert.equal(res.statusCode, 200) assert.equal(cleared, true) // Both halves matter: clearing the column alone would leave a link already // sitting in a mailbox able to install the address the user just abandoned. assert.equal(retired, true, 'outstanding links must be retired too') })