feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
278
server/test/emailVerification.test.js
Normal file
278
server/test/emailVerification.test.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// 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')
|
||||
})
|
||||
Reference in New Issue
Block a user