Files
website/server/test/authController.test.js
wtclaude 35e5269ec5 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>
2026-07-21 00:33:01 -05:00

286 lines
12 KiB
JavaScript

// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every model/service 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 auth controller's security-sensitive decision logic. These are
// the rules a regression must never silently break:
// - a tripped honeypot fails generically and is never issued a session;
// - every credential failure returns the SAME generic message (no user
// enumeration, no "which field was wrong");
// - correct password on an inactive account is refused (no session, no TOTP);
// - a TOTP-enabled user gets a challenge, NOT a session, until the code checks;
// - registration is gated by the registration mode; a dup username is a 409;
// - logout always clears the cookie and revokes the session when present.
const ctrl = require('../src/router/v1/auth/auth.controller')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const settings = require('../src/model/settings/settings.model')
const sessionService = require('../src/auth/session.service')
const totp = require('../src/utils/totp')
const botScore = require('../src/middleware/botScore')
const loginProtection = require('../src/middleware/loginProtection')
const db = require('../src/utils/db')
after(() => db.close())
function mockRes() {
return {
statusCode: 200,
body: null,
cookies: {},
cleared: [],
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
cookie(name, val) {
this.cookies[name] = val
return this
},
clearCookie(name) {
this.cleared.push(name)
return this
},
}
}
// Spies / stubs for the collaborators the controller drives.
let sessionsCreated
const orig = {}
beforeEach(() => {
sessionsCreated = []
for (const [mod, name] of [
[users, 'getRawByUsername'], [users, 'validatePassword'], [users, 'recordLogin'],
[users, 'getRawById'], [users, 'getById'], [users, 'createUser'], [users, 'isDuplicateUsername'],
[activity, 'log'], [settings, 'getRegistrationMode'],
[sessionService, 'createSession'], [sessionService, 'createPartialSession'], [sessionService, 'upgradeSessionAfterTotp'], [sessionService, 'revokeSession'],
[totp, 'verifyCode'],
[botScore, 'recordHoneypot'], [botScore, 'recordLoginFailure'],
[loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'],
]) {
orig[`${name}`] = orig[`${name}`] || { mod, val: mod[name] }
}
// Safe defaults; individual tests override.
users.recordLogin = async () => {}
users.getById = async (id) => ({ id, username: 'u', role: 'player' })
activity.log = async () => {}
botScore.recordHoneypot = () => {}
botScore.recordLoginFailure = () => {}
loginProtection.recordFailure = () => {}
loginProtection.recordSuccess = () => {}
sessionService.createSession = (user) => {
sessionsCreated.push(user)
return { token: 'session-token' }
}
})
afterEach(() => {
for (const key of Object.keys(orig)) {
orig[key].mod[key] = orig[key].val
delete orig[key]
}
})
const baseReq = (body = {}) => ({ body, ip: '10.0.0.1', headers: {}, session: null })
// ── honeypot ────────────────────────────────────────────────────────────
test('login: a filled honeypot field fails generically and issues no session', async () => {
let scored = false
botScore.recordHoneypot = () => {
scored = true
}
const res = mockRes()
await ctrl.login(baseReq({ username: 'x', password: 'y', company: 'ACME Bot' }), res)
assert.equal(res.statusCode, 401)
assert.equal(res.body.message, 'Incorrect username or password.')
assert.equal(scored, true)
assert.equal(sessionsCreated.length, 0)
})
// ── credential failures are indistinguishable ───────────────────────────
test('login: an unknown username and a wrong password return the identical generic failure', async () => {
users.getRawByUsername = async () => null // unknown user
users.validatePassword = async () => false
const res1 = mockRes()
await ctrl.login(baseReq({ username: 'ghost', password: 'z' }), res1)
users.getRawByUsername = async () => ({ id: 1, username: 'real', status: 'active' })
users.validatePassword = async () => false // wrong password
const res2 = mockRes()
await ctrl.login(baseReq({ username: 'real', password: 'wrong' }), res2)
assert.equal(res1.statusCode, 401)
assert.equal(res2.statusCode, 401)
assert.deepEqual(res1.body, res2.body) // no enumeration: same message either way
assert.equal(sessionsCreated.length, 0)
})
// ── inactive account: correct password, still refused ───────────────────
test('login: correct password on a non-active account is a 403 with no session', async () => {
users.getRawByUsername = async () => ({ id: 1, username: 'banned', status: 'banned' })
users.validatePassword = async () => true
const res = mockRes()
await ctrl.login(baseReq({ username: 'banned', password: 'right' }), res)
assert.equal(res.statusCode, 403)
assert.equal(sessionsCreated.length, 0)
})
// ── TOTP gate: password ok but a second factor is required ──────────────
test('login: a TOTP-enabled user gets a challenge, not a session', async () => {
users.getRawByUsername = async () => ({ id: 5, username: 'safe', status: 'active', totp_enabled: 1 })
users.validatePassword = async () => true
sessionService.createPartialSession = () => 'challenge-jwt'
const res = mockRes()
await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res)
assert.equal(res.body.totpRequired, true)
assert.equal(res.body.challenge, 'challenge-jwt')
assert.equal(sessionsCreated.length, 0, 'no real session until the code is verified')
assert.equal(res.cookies['auth_token'] || res.cookies.token, undefined)
})
test('login: a user without TOTP is logged straight in (session + cookie)', async () => {
users.getRawByUsername = async () => ({ id: 9, username: 'plain', role: 'player', status: 'active', totp_enabled: 0 })
users.validatePassword = async () => true
const res = mockRes()
await ctrl.login(baseReq({ username: 'plain', password: 'right' }), res)
assert.equal(sessionsCreated.length, 1)
assert.equal(sessionsCreated[0].id, 9)
assert.equal(res.body.user.id, 9)
assert.equal(Object.keys(res.cookies).length, 1, 'a session cookie was set')
})
// ── loginTotp second step ───────────────────────────────────────────────
test('loginTotp: an expired/invalid challenge is a 401 (no user lookup)', async () => {
sessionService.upgradeSessionAfterTotp = () => null
let lookedUp = false
users.getRawById = async () => {
lookedUp = true
return null
}
const res = mockRes()
await ctrl.loginTotp(baseReq({ challenge: 'stale', code: '000000' }), res)
assert.equal(res.statusCode, 401)
assert.equal(lookedUp, false)
})
test('loginTotp: a wrong code is a 401 and issues no session', async () => {
sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
users.getRawById = async () => ({ id: 5, totp_enabled: 1, totp_secret: 'S' })
totp.verifyCode = () => false
const res = mockRes()
await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '123456' }), res)
assert.equal(res.statusCode, 401)
assert.equal(sessionsCreated.length, 0)
})
test('loginTotp: a valid code issues the session with authMethod totp', async () => {
sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
users.getRawById = async () => ({ id: 5, username: 'safe', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
totp.verifyCode = () => true
let method
sessionService.createSession = (user, authMethod) => {
method = authMethod
sessionsCreated.push(user)
return { token: 't' }
}
const res = mockRes()
await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '654321' }), res)
assert.equal(sessionsCreated.length, 1)
assert.equal(method, 'totp')
assert.equal(res.body.user.id, 5)
})
// ── register gating ─────────────────────────────────────────────────────
test('register: refused (403) when the registration mode excludes passwords', async () => {
settings.getRegistrationMode = async () => 'sso' // password path closed
const res = mockRes()
await ctrl.register(baseReq({ username: 'newbie', password: 'pw' }), res)
assert.equal(res.statusCode, 403)
})
test('register: an invalid username is a 400 before any user is created', async () => {
settings.getRegistrationMode = async () => 'password'
let created = false
users.createUser = async () => {
created = true
}
const res = mockRes()
await ctrl.register(baseReq({ username: 'x', password: 'pw' }), res) // too short
assert.equal(res.statusCode, 400)
assert.equal(created, false)
})
test('register: a duplicate username surfaces as a 409', async () => {
settings.getRegistrationMode = async () => 'both'
users.createUser = async () => {
throw new Error('dup')
}
users.isDuplicateUsername = () => true
const res = mockRes()
await ctrl.register(baseReq({ username: 'takenname', password: 'pw' }), res)
assert.equal(res.statusCode, 409)
})
test('register: a valid new player is created and auto-logged-in', async () => {
settings.getRegistrationMode = async () => 'password'
users.createUser = async ({ username, role }) => ({ id: 30, username, role })
const res = mockRes()
await ctrl.register(baseReq({ username: 'freshplayer', password: 'pw', email: ' a@b.c ' }), res)
assert.equal(res.body.user.id, 30)
assert.equal(res.body.user.role, 'player')
assert.equal(sessionsCreated.length, 1)
})
// ── logout always clears, revokes when a session exists ─────────────────
test('logout: clears the cookie and revokes the server-side session when present', async () => {
let revoked = null
sessionService.revokeSession = async (id) => {
revoked = id
}
const req = { ...baseReq(), session: { sessionId: 'jti-1', userId: 7, expiresAt: 123 } }
const res = mockRes()
await ctrl.logout(req, res)
assert.equal(res.cleared.length, 1, 'cookie cleared')
assert.equal(revoked, 'jti-1')
})
test('logout: still succeeds (cookie cleared) when there is no session to revoke', async () => {
let revokeCalled = false
sessionService.revokeSession = async () => {
revokeCalled = true
}
const res = mockRes()
await ctrl.logout(baseReq(), res)
assert.equal(res.cleared.length, 1)
assert.equal(revokeCalled, false)
assert.match(res.body.message, /logged out/i)
})
test('logout: a revocation error never fails the logout', async () => {
sessionService.revokeSession = async () => {
throw new Error('store unreachable')
}
const req = { ...baseReq(), session: { sessionId: 'jti', userId: 1, expiresAt: 1 } }
const res = mockRes()
await ctrl.logout(req, res) // must resolve
assert.equal(res.cleared.length, 1)
assert.match(res.body.message, /logged out/i)
})
// ── me ──────────────────────────────────────────────────────────────────
test('me: returns 401 when the backing user no longer exists', async () => {
users.getById = async () => null
const res = mockRes()
await ctrl.me({ ...baseReq(), user: { id: 99 } }, res)
assert.equal(res.statusCode, 401)
})