// Point the DB at a closed port BEFORE requiring the controller (its models build // the pool). Every model/mailer call is monkeypatched, so no query runs; // db.close() at the end releases the pool so the process exits cleanly. 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') // Unit-test the self-service password reset controller. The security invariants: // - requestReset NEVER reveals whether an email exists — empty, unmatched, // matched, and even an internal error all return the same generic 200; // - one account's mail failure does not abort the others or change the answer; // - confirmReset consumes the token atomically (a lost double-submit race is a // 404) and, on success, rotates the password AND revokes mobile sessions, // without auto-logging the user in. const ctrl = require('../src/router/v1/auth/passwordReset.controller') const passwordResets = require('../src/model/passwordResets/passwordResets.model') const users = require('../src/model/users/users.model') const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model') const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model') const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.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 }, } } const GENERIC_MATCH = /if an account exists/i const orig = {} beforeEach(() => { for (const [mod, name] of [ [users, 'getActiveByEmail'], [users, 'getById'], [users, 'update'], [passwordResets, 'create'], [passwordResets, 'findValidByToken'], [passwordResets, 'consume'], [passwordResets, 'invalidatePendingForUser'], [mobileSessions, 'revokeAllForUser'], [trustedDevices, 'revokeAllForUser'], [recoveryCodes, 'clearForUser'], [activity, 'log'], [mailer, 'sendPasswordReset'], ]) { orig[name] = { mod, val: mod[name] } } activity.log = async () => {} mailer.sendPasswordReset = async () => ({ sent: true }) passwordResets.create = async () => ({ token: 'opaque-token' }) passwordResets.invalidatePendingForUser = async () => {} mobileSessions.revokeAllForUser = async () => {} trustedDevices.revokeAllForUser = async () => {} recoveryCodes.clearForUser = async () => {} }) afterEach(() => { for (const key of Object.keys(orig)) { orig[key].mod[key] = orig[key].val delete orig[key] } }) const req = (body = {}) => ({ body, ip: '10.0.0.1' }) // ── requestReset never enumerates ─────────────────────────────────────── test('requestReset returns the generic OK for an empty email without any lookup', async () => { let lookedUp = false users.getActiveByEmail = async () => { lookedUp = true return [] } const res = mockRes() await ctrl.requestReset(req({ email: ' ' }), res) assert.equal(res.statusCode, 200) assert.match(res.body.message, GENERIC_MATCH) assert.equal(lookedUp, false, 'a blank email is short-circuited before the DB') }) test('requestReset returns the SAME generic OK whether or not the email matched', async () => { users.getActiveByEmail = async () => [] // no account const resNone = mockRes() await ctrl.requestReset(req({ email: 'ghost@x.io' }), resNone) users.getActiveByEmail = async () => [{ id: 1, email: 'real@x.io', username: 'real' }] const resHit = mockRes() await ctrl.requestReset(req({ email: 'real@x.io' }), resHit) assert.deepEqual(resNone.body, resHit.body) // indistinguishable assert.equal(resHit.statusCode, 200) }) test('requestReset emails every account matching a (non-unique) address', async () => { users.getActiveByEmail = async () => [ { id: 1, email: 'shared@x.io', username: 'alpha' }, { id: 2, email: 'shared@x.io', username: 'beta' }, ] const sent = [] mailer.sendPasswordReset = async ({ username }) => { sent.push(username) return { sent: true } } await ctrl.requestReset(req({ email: 'shared@x.io' }), mockRes()) assert.deepEqual(sent.sort(), ['alpha', 'beta']) }) test("requestReset: one account's mail failure does not abort the others or change the response", async () => { users.getActiveByEmail = async () => [ { id: 1, email: 'a@x.io', username: 'alpha' }, { id: 2, email: 'b@x.io', username: 'beta' }, ] const sent = [] mailer.sendPasswordReset = async ({ username }) => { if (username === 'alpha') throw new Error('smtp reject') sent.push(username) return { sent: true } } const res = mockRes() await ctrl.requestReset(req({ email: 'a@x.io' }), res) assert.deepEqual(sent, ['beta'], 'beta still got its link after alpha failed') assert.match(res.body.message, GENERIC_MATCH) }) test('requestReset stays generic even when the account lookup itself throws', async () => { users.getActiveByEmail = async () => { throw new Error('pool down') } const res = mockRes() await ctrl.requestReset(req({ email: 'x@x.io' }), res) assert.equal(res.statusCode, 200) // internal error is not an enumeration oracle assert.match(res.body.message, GENERIC_MATCH) }) // ── lookupReset ───────────────────────────────────────────────────────── test('lookupReset 404s an invalid/expired token and returns only the username on success', async () => { passwordResets.findValidByToken = async () => null const res404 = mockRes() await ctrl.lookupReset({ params: { token: 'bad' } }, res404) assert.equal(res404.statusCode, 404) passwordResets.findValidByToken = async () => ({ user_id: 7 }) users.getById = async () => ({ id: 7, username: 'target', email: 'secret@x.io' }) const resOk = mockRes() await ctrl.lookupReset({ params: { token: 'good' } }, resOk) assert.deepEqual(resOk.body, { username: 'target' }) // email/token never surfaced }) // ── confirmReset consume race + revoke-everywhere ─────────────────────── test('confirmReset 404s when the token is not valid', async () => { passwordResets.findValidByToken = async () => null const res = mockRes() await ctrl.confirmReset({ params: { token: 'bad' }, body: { password: 'new' } }, res) assert.equal(res.statusCode, 404) }) test('confirmReset 404s the loser of a double-submit race and never rotates the password', async () => { passwordResets.findValidByToken = async () => ({ id: 3, user_id: 7 }) passwordResets.consume = async () => false // lost the race let rotated = false users.update = async () => { rotated = true } const res = mockRes() await ctrl.confirmReset({ params: { token: 't' }, body: { password: 'new' } }, res) assert.equal(res.statusCode, 404) assert.equal(rotated, false) }) test('confirmReset rotates the password, revokes mobile sessions, and retires other links — no auto-login', async () => { passwordResets.findValidByToken = async () => ({ id: 3, user_id: 7 }) passwordResets.consume = async () => true const calls = { update: null, revoke: null, invalidate: null } users.update = async (id, patch) => { calls.update = { id, patch } } mobileSessions.revokeAllForUser = async (id) => { calls.revoke = id } passwordResets.invalidatePendingForUser = async (id) => { calls.invalidate = id } const res = mockRes() await ctrl.confirmReset({ params: { token: 't' }, body: { password: 'brand-new' } }, res) assert.deepEqual(calls.update, { id: 7, patch: { password: 'brand-new' } }) assert.equal(calls.revoke, 7) assert.equal(calls.invalidate, 7) assert.equal(res.body.ok, true) assert.equal(res.body.user, undefined, 'no session/user is returned — the user signs in fresh') })