Files
website/server/test/emailVerification.test.js
wtclaude fbb4b0bd91
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s
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>
2026-08-29 01:53:50 -05:00

279 lines
11 KiB
JavaScript

// 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')
})