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:
2026-07-21 00:33:01 -05:00
parent 99fd9acddb
commit 35e5269ec5
5 changed files with 998 additions and 0 deletions

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