// Point the DB at a closed port BEFORE requiring the controller (its models build // the pool). Every collaborator is monkeypatched, so no query runs. 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 trusted-device + recovery-code additions to the web auth flow: // - a TOTP user on a trusted device (bound to THEM) skips the second factor; // - a trust token bound to a DIFFERENT user is ignored (challenge as usual); // - loginTotp accepts a single-use recovery code as an alternative second factor; // - trustDevice sets the trust cookie under the cap, and surfaces a // { trustLimitReached, devices } prompt (session still issued) at the cap. 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 sessionService = require('../src/auth/session.service') const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model') const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model') 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: {}, 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.cookies[name] = undefined; return this }, } } let sessionsCreated const orig = {} beforeEach(() => { sessionsCreated = [] for (const [mod, name] of [ [users, 'getRawByUsername'], [users, 'validatePassword'], [users, 'recordLogin'], [users, 'getRawById'], [activity, 'log'], [sessionService, 'createSession'], [sessionService, 'createPartialSession'], [sessionService, 'upgradeSessionAfterTotp'], [sessionService, 'resolveTrustedDevice'], [sessionService, 'honorTrustedDevice'], [sessionService, 'trustDeviceCapReached'], [sessionService, 'mintTrustToken'], [sessionService, 'sessionMeta'], [trustedDevices, 'store'], [trustedDevices, 'listActiveForUser'], [recoveryCodes, 'consumeForUser'], [totp, 'verifyCode'], [botScore, 'recordLoginFailure'], [loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'], ]) { orig[name] = orig[name] || { mod, val: mod[name] } } users.recordLogin = async () => {} activity.log = async () => {} botScore.recordLoginFailure = () => {} loginProtection.recordFailure = () => {} loginProtection.recordSuccess = () => {} sessionService.createSession = (user, authMethod) => { sessionsCreated.push({ user, authMethod }) return { token: 'session-token' } } sessionService.createPartialSession = () => 'challenge-jwt' sessionService.honorTrustedDevice = async () => true sessionService.sessionMeta = () => ({ deviceHash: 'dh', userAgent: 'UA' }) }) 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: {} }) // ── login(): trusted-device skips TOTP ──────────────────────────────────── test('login: a TOTP user on a device trusted by THEM skips the code and gets a session', async () => { users.getRawByUsername = async () => ({ id: 5, username: 'safe', role: 'admin', status: 'active', totp_enabled: 1 }) users.validatePassword = async () => true sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 5 }) let honored = null sessionService.honorTrustedDevice = async (id) => { honored = id } const res = mockRes() await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res) assert.equal(sessionsCreated.length, 1, 'session issued without a TOTP challenge') assert.equal(sessionsCreated[0].authMethod, 'totp') assert.equal(honored, 11, 'the trusted device was stamped as used') assert.equal(res.body.totpRequired, undefined) }) test('login: a trust token bound to a DIFFERENT user is ignored (challenge as usual)', async () => { users.getRawByUsername = async () => ({ id: 5, username: 'safe', status: 'active', totp_enabled: 1 }) users.validatePassword = async () => true sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 999 }) // someone else's device const res = mockRes() await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res) assert.equal(res.body.totpRequired, true) assert.equal(sessionsCreated.length, 0) }) test('login: a trusted-device lookup error falls back to the TOTP challenge (fail closed)', async () => { users.getRawByUsername = async () => ({ id: 5, username: 'safe', status: 'active', totp_enabled: 1 }) users.validatePassword = async () => true sessionService.resolveTrustedDevice = async () => { throw new Error('store down') } const res = mockRes() await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res) assert.equal(res.body.totpRequired, true) assert.equal(sessionsCreated.length, 0) }) // ── loginTotp(): recovery code as an alternative factor ─────────────────── test('loginTotp: a valid recovery code (no TOTP code) issues the session and is consumed', async () => { sessionService.upgradeSessionAfterTotp = () => ({ id: 5 }) users.getRawById = async () => ({ id: 5, username: 'safe', role: 'player', totp_enabled: 1, totp_secret: 'S' }) totp.verifyCode = () => false let consumed = null recoveryCodes.consumeForUser = async (id, code) => { consumed = { id, code }; return true } const res = mockRes() await ctrl.loginTotp(baseReq({ challenge: 'ok', recoveryCode: 'abcde-12345' }), res) assert.equal(sessionsCreated.length, 1) assert.deepEqual(consumed, { id: 5, code: 'abcde-12345' }) assert.equal(res.body.user.id, 5) }) test('loginTotp: neither a valid code nor a valid recovery code is a 401, no session', async () => { sessionService.upgradeSessionAfterTotp = () => ({ id: 5 }) users.getRawById = async () => ({ id: 5, totp_enabled: 1, totp_secret: 'S' }) totp.verifyCode = () => false recoveryCodes.consumeForUser = async () => false const res = mockRes() await ctrl.loginTotp(baseReq({ challenge: 'ok', recoveryCode: 'nope' }), res) assert.equal(res.statusCode, 401) assert.equal(sessionsCreated.length, 0) }) // ── loginTotp(): trustDevice opt-in ─────────────────────────────────────── test('loginTotp: trustDevice under the cap sets the trust cookie', async () => { sessionService.upgradeSessionAfterTotp = () => ({ id: 5 }) users.getRawById = async () => ({ id: 5, username: 'safe', role: 'admin', totp_enabled: 1, totp_secret: 'S' }) totp.verifyCode = () => true sessionService.trustDeviceCapReached = async () => false sessionService.mintTrustToken = () => ({ trustToken: 'TRUST-RAW', trustHash: 'H', deviceHash: null, userAgent: null, expiresAt: new Date() }) let stored = null trustedDevices.store = async (row) => { stored = row } const res = mockRes() await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '654321', trustDevice: true, deviceName: 'My Laptop' }), res) assert.equal(sessionsCreated.length, 1) assert.equal(res.cookies.rg_trust, 'TRUST-RAW', 'the trust cookie was set') assert.equal(stored.userId, 5) assert.equal(stored.platform, 'web') assert.equal(stored.deviceName, 'My Laptop') assert.equal(res.body.trustLimitReached, undefined) }) test('loginTotp: trustDevice at the cap still issues the session but returns the limit prompt', async () => { sessionService.upgradeSessionAfterTotp = () => ({ id: 5 }) users.getRawById = async () => ({ id: 5, username: 'safe', role: 'admin', totp_enabled: 1, totp_secret: 'S' }) totp.verifyCode = () => true sessionService.trustDeviceCapReached = async () => true trustedDevices.listActiveForUser = async () => [{ id: 1, device_name: 'Old', created_at: 't', last_used_at: 't', expires_at: 't', platform: 'web' }] let stored = false trustedDevices.store = async () => { stored = true } const res = mockRes() await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '654321', trustDevice: true }), res) assert.equal(sessionsCreated.length, 1, 'login still succeeds') assert.equal(res.cookies.rg_trust, undefined, 'no trust cookie at the cap') assert.equal(stored, false, 'no new trust row created') assert.equal(res.body.trustLimitReached, true) assert.equal(res.body.devices.length, 1) })