test(server): unit-test auth, invite, password-reset, and public controllers
Add controller-level unit tests (mock req/res, monkeypatched collaborators) focused on security boundaries and decision logic the API must not regress: - auth.controller: honeypot handling, non-enumerating generic-fail for every credential failure, inactive-account refusal, the TOTP challenge branch that must NOT issue a session, register-mode gating + dup-username 409, and logout that always clears the cookie and revokes the session (even on error). - invite.controller: user created at the invite's PRESET role, and the lost double-accept race rolling back the just-created user. - passwordReset.controller: identical generic 200 whether or not the email matched (incl. internal errors), per-account mail-failure isolation, the single-use consume race, and revoke-everywhere-on-reset with no auto-login. - public.controller: staff-only draft visibility, token-gated page preview, wiki search precedence + unknown-filter handling, contact 502. - shard.controller (public): the PUBLIC_KINDS feed allowlist and the public house view stripping owner/price — both leak-prevention boundaries. Lifts: auth.controller 46%→94%, passwordReset 33%→93%, public.controller 28%→65%, shard.controller 45%→68% line coverage; server aggregate 63.5%→70.4%. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
191
server/test/passwordResetController.test.js
Normal file
191
server/test/passwordResetController.test.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// 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 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'], [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 () => {}
|
||||
})
|
||||
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')
|
||||
})
|
||||
Reference in New Issue
Block a user