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>
286 lines
12 KiB
JavaScript
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)
|
|
})
|