// 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) })