// Set before requiring the auth layer (token.js reads JWT_SECRET at load) and // db.js (pulled in transitively; the pool builds at load). Closed DB port keeps // idle connections from holding the process open — these tests are DB-free and // only exercise the pure token/hash logic of the mobile session service. process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret' process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, after } = require('node:test') const assert = require('node:assert/strict') const jwt = require('jsonwebtoken') const sessionService = require('../src/auth/session.service') const tokenLib = require('../src/auth/token') const db = require('../src/utils/db') after(() => db.close()) const USER = { id: 42, username: 'mobileuser', role: 'admin' } test('createMobileSession mints a bearer-validatable access token + opaque refresh token', () => { const out = sessionService.createMobileSession(USER, { deviceHash: 'abc', userAgent: 'Android' }) // Access token validates as a mobile session. const session = sessionService.validateBearerToken(out.accessToken) assert.ok(session) assert.equal(session.userId, USER.id) assert.equal(session.authMethod, 'mobile') assert.ok(session.sessionId) // Refresh token is opaque (not a JWT) and its stored form is the sha256 hash. assert.equal(typeof out.refreshToken, 'string') assert.ok(out.refreshToken.length >= 40) assert.equal(out.refreshHash, sessionService.hashRefreshToken(out.refreshToken)) assert.equal(sessionService.validateBearerToken(out.refreshToken), null, 'refresh token is not a bearer session') // Metadata + a future expiry are carried through for the controller to persist. assert.equal(out.deviceHash, 'abc') assert.equal(out.userAgent, 'Android') assert.ok(out.refreshExpiresAt instanceof Date) assert.ok(out.refreshExpiresAt.getTime() > Date.now()) }) test('access token is short-lived (mobile TTL, not the 1d web default)', () => { const { accessToken } = sessionService.createMobileSession(USER) const decoded = jwt.decode(accessToken) const lifetime = decoded.exp - decoded.iat // Default MOBILE_ACCESS_TTL is 15m — comfortably under the 1d web session. assert.ok(lifetime <= 15 * 60, `access token lifetime ${lifetime}s should be <= 15m`) }) test('refreshMobileSession issues a distinct new pair (rotation)', () => { const a = sessionService.createMobileSession(USER) const b = sessionService.refreshMobileSession(USER) assert.notEqual(a.refreshToken, b.refreshToken) assert.notEqual(a.refreshHash, b.refreshHash) }) test('hashRefreshToken is stable and deterministic', () => { assert.equal(sessionService.hashRefreshToken('token-xyz'), sessionService.hashRefreshToken('token-xyz')) assert.notEqual(sessionService.hashRefreshToken('a'), sessionService.hashRefreshToken('b')) // sha256 hex is 64 chars. assert.equal(sessionService.hashRefreshToken('anything').length, 64) }) test('validateBearerToken rejects a TOTP challenge and garbage', () => { const challenge = sessionService.createPartialSession(USER) assert.equal(sessionService.validateBearerToken(challenge), null) assert.equal(sessionService.validateBearerToken('not-a-jwt'), null) assert.equal(sessionService.validateBearerToken(null), null) }) test('token.signToken honors an expiresIn override, else uses the default', () => { const short = tokenLib.signToken(USER, {}, { expiresIn: '1s' }) const shortDecoded = jwt.decode(short) assert.equal(shortDecoded.exp - shortDecoded.iat, 1) // No option → historical default (JWT_EXPIRES_IN, 1d) unchanged. const dflt = jwt.decode(tokenLib.signToken(USER)) assert.equal(dflt.exp - dflt.iat, 24 * 60 * 60) })