// Integration test for the TOTP two-step login + recovery codes. // // Mounts the real auth router with a session store on the throwaway test DB. // Drives: enroll (setup → enable) → logout → password login returns mfa_required // → complete with a generated code → and the recovery-code single-use path. // Skips when TEST_DATABASE_URL is unset. import { test } from 'node:test'; import assert from 'node:assert/strict'; import express from 'express'; import session from 'express-session'; import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; import { hashPassword } from '../../src/auth/passwords.js'; import { generateToken } from '../../src/auth/totp.js'; const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set'; async function appWithAuth(pool) { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; process.env.AUTH_ENABLED = 'true'; process.env.SESSION_SECRET = 'test'; const ConnectPg = (await import('connect-pg-simple')).default(session); const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now()); const app = express(); app.use(express.json()); app.use(session({ store: new ConnectPg({ pool, tableName: 'sessions' }), secret: 'test', name: 'dragonflight.sid', cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 }, rolling: false, resave: false, saveUninitialized: false, })); app.use('/api/v1/auth', authRouter); return new Promise(r => { const srv = app.listen(0, '127.0.0.1', () => r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) })); }); } const J = (cookie, body) => ({ method: 'POST', headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body), }); async function loginPassword(baseUrl, username, password) { const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password })); const cookie = (r.headers.get('set-cookie') || '').split(';')[0]; return { r, body: await r.json().catch(() => ({})), cookie }; } test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]); const { baseUrl, close } = await appWithAuth(pool); // 1. Password login (no TOTP yet) → 200 with a session cookie. const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery'); assert.equal(first.r.status, 200); assert.ok(!first.body.mfa_required); const cookie = first.cookie; // 2. Enroll: setup returns a secret; enable confirms with a live code. const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json(); assert.match(setup.secret, /^[A-Z2-7]+$/); const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) })); assert.equal(enableRes.status, 200); const enableBody = await enableRes.json(); assert.equal(enableBody.enabled, true); assert.equal(enableBody.recovery_codes.length, 10); // 3. Fresh password login now returns mfa_required + a ticket, NO session cookie. const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery'); assert.equal(second.r.status, 200); assert.equal(second.body.mfa_required, true); assert.ok(second.body.ticket); assert.ok(!second.cookie, 'no session cookie should be set before the second factor'); // 4. Wrong code → 401; the ticket is now spent. const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' })); assert.equal(bad.status, 401); // 5. New login + correct code → 200 with a session cookie. const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery'); const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) })); assert.equal(ok.status, 200); assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/); await close(); } finally { await pool.end(); } }); test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]); const { baseUrl, close } = await appWithAuth(pool); const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery'); const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json(); const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json(); const recovery = enableBody.recovery_codes[0]; // Use a recovery code to complete a fresh login. const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery'); const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery })); assert.equal(use1.status, 200, 'recovery code should complete login once'); // The same recovery code must NOT work a second time. const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery'); const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery })); assert.equal(use2.status, 401, 'a spent recovery code must be rejected'); await close(); } finally { await pool.end(); } }); test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]); const { baseUrl, close } = await appWithAuth(pool); const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery'); const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json(); await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) })); // Disabling requires the password. const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' })); assert.equal(wrongPw.status, 400); const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' })); assert.equal(disabled.status, 204); // Password login is single-factor again. const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery'); assert.equal(relog.r.status, 200); assert.ok(!relog.body.mfa_required); await close(); } finally { await pool.end(); } });