/** * Integration tests for routes/auth/totp.js — the full TOTP auth flow. * * Covers the BACKLOG.md DC-006 acceptance criteria: * - no code → 400 (ValidationError) * - wrong code → 401 (AuthenticationError) * - valid TOTP → 200 + session cookie + CSRF token + SSO handoff token * - check-session with valid session → 200 { success: true, authenticated: true } * - check-session without session → 401 (AuthenticationError) * * Uses real otplib for code generation (so we exercise the actual TOTP math) * but mocks credentialManager, session, totpConfig, and saveTotpConfig — * because those modules own their own state machines (disk, cookies, file) * that don't belong in a routes-level test. * * NOTE: this test exercises the src/ refactored module layout (DC-005). * It depends on routes/auth/totp.js requiring ../../src/utilities/errors and * ../../src/utils/responses — fix the relative paths in totp.js if they * regress (see commit log for DC-006). */ const express = require('express'); const request = require('supertest'); const { authenticator } = require('otplib'); // Quiet otplib's "Unescaped left brace" warning on Node 20+ const origWarn = console.warn; beforeAll(() => { console.warn = (...args) => { const msg = args.join(' '); if (msg.includes('Unescaped left brace')) return; origWarn.apply(console, args); }; }); afterAll(() => { console.warn = origWarn; }); // Minimal asyncHandler that catches errors into the express error chain function asyncHandler(fn) { return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next); } function createApp(depsOverride = {}) { // In-memory secret store so credentialManager stays deterministic const storedSecrets = new Map(); const credentialManager = { store: jest.fn((key, value) => { storedSecrets.set(key, value); return Promise.resolve(true); }), retrieve: jest.fn((key) => Promise.resolve(storedSecrets.has(key) ? storedSecrets.get(key) : null)), delete: jest.fn((key) => { storedSecrets.delete(key); return Promise.resolve(true); }), list: jest.fn(() => Promise.resolve(Array.from(storedSecrets.keys()))), }; // Mutable TOTP config — tests mutate this to model setup → enable → disable const totpConfig = { enabled: false, isSetUp: false, sessionDuration: '24h', secret: null, // matches main's optional backup-secret field }; // Mock session context mirroring src/context/session.js // isValid() is the knob — toggle it to test the auth-gate behavior const sessionStore = new Map(); // ip → { expiresAt } const session = { create: jest.fn((req, duration) => { const ip = session.getClientIP(req); sessionStore.set(ip, { expiresAt: Date.now() + (duration === 'never' ? Number.MAX_SAFE_INTEGER : 3600000) }); }), setCookie: jest.fn(), clear: jest.fn((req) => { const ip = session.getClientIP(req); sessionStore.delete(ip); }), clearCookie: jest.fn(), createHandoffToken: jest.fn(() => 'mock-sso-handoff-token'), isValid: jest.fn((req) => { const ip = session.getClientIP(req); const entry = sessionStore.get(ip); if (!entry) return false; return entry.expiresAt > Date.now(); }), // Test helper — pretend an IP has a valid session, regardless of req.ip _grantSession: (ip = '127.0.0.1') => sessionStore.set(ip, { expiresAt: Date.now() + 3600000 }), getClientIP: jest.fn((req) => req.ip || req.connection?.remoteAddress || '127.0.0.1'), ipSessions: sessionStore, durations: { '1h': 3600000, '24h': 86400000, '7d': 604800000, 'never': 0 }, }; const saveTotpConfig = jest.fn(() => Promise.resolve(true)); const renewCSRFToken = jest.fn(() => 'mock-csrf-token'); const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), }; const deps = { authManager: {}, // unused by totp.js but required by the factory signature credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse: jest.fn(), log, renewCSRFToken, siteConfig: { tld: '.sami', dashboardHost: 'status.sami' }, ...depsOverride, }; // Clear store between tests deps._resetStore = () => { storedSecrets.clear(); sessionStore.clear(); totpConfig.enabled = false; totpConfig.isSetUp = false; totpConfig.sessionDuration = '24h'; delete totpConfig.secret; }; const totpRoutes = require('../../routes/auth/totp'); const app = express(); app.set('trust proxy', true); // so req.ip populates from X-Forwarded-For app.use(express.json()); app.use('/api', totpRoutes(deps)); // Express error handler — surface status from thrown AppError app.use((err, req, res, _next) => { const status = err.statusCode || 500; res.status(status).json({ success: false, error: err.message }); }); return { app, deps }; } describe('TOTP Auth Routes — DC-006 Integration Test', () => { let app; let deps; beforeEach(() => { jest.clearAllMocks(); ({ app, deps } = createApp()); authenticator.options = { window: 1 }; }); // Helper: derive a fresh secret + a valid current TOTP code for it function freshSecret() { const secret = authenticator.generateSecret(); const token = authenticator.generate(secret); return { secret, token }; } // ──────────────────────────────────────────────────────────────────── // GET /api/totp/config // ──────────────────────────────────────────────────────────────────── describe('GET /api/totp/config', () => { it('returns current config (enabled=false, isSetUp=false by default)', async () => { const res = await request(app).get('/api/totp/config'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.config).toEqual({ enabled: false, sessionDuration: '24h', isSetUp: false, }); }); it('reflects state changes after setup completes', async () => { deps.totpConfig.isSetUp = true; deps.totpConfig.enabled = true; const res = await request(app).get('/api/totp/config'); expect(res.status).toBe(200); expect(res.body.config.isSetUp).toBe(true); expect(res.body.config.enabled).toBe(true); }); }); // ──────────────────────────────────────────────────────────────────── // POST /api/totp/setup // ──────────────────────────────────────────────────────────────────── describe('POST /api/totp/setup', () => { it('generates a fresh secret + QR code when none is provided', async () => { const res = await request(app).post('/api/totp/setup').send({}); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.qrCode).toMatch(/^data:image\/png;base64,/); expect(res.body.manualKey).toMatch(/^[A-Z2-7]{16,}$/); expect(res.body.issuer).toBe('DashCaddy'); expect(res.body.imported).toBe(false); // pending_secret should be stashed but totp.secret should NOT be active yet expect(deps.credentialManager.store).toHaveBeenCalledWith('totp.pending_secret', res.body.manualKey); expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull(); }); it('accepts and normalizes a user-provided Base32 secret (0→O, 1→L, 8→B, lowercase→uppercase)', async () => { const raw = 'JBSWY3DPEHPK3PXP'; // canonical example const userInput = ' jbswy3dpehpk3pxp '; // spaces + lowercase const res = await request(app).post('/api/totp/setup').send({ secret: userInput }); expect(res.status).toBe(200); expect(res.body.manualKey).toBe(raw); expect(res.body.imported).toBe(true); }); it('rejects an obviously invalid secret (wrong alphabet)', async () => { const res = await request(app).post('/api/totp/setup').send({ secret: 'NOT-VALID-BASE32!' }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); expect(res.body.error).toMatch(/Invalid secret key format/); }); }); // ──────────────────────────────────────────────────────────────────── // POST /api/totp/verify-setup (activates TOTP after setup) // ──────────────────────────────────────────────────────────────────── describe('POST /api/totp/verify-setup', () => { it('returns 400 when code is missing or malformed', async () => { const res = await request(app).post('/api/totp/verify-setup').send({}); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Invalid code format/); }); it('returns 400 when no pending setup exists', async () => { const { token } = freshSecret(); const res = await request(app).post('/api/totp/verify-setup').send({ code: token }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/No pending TOTP setup/); }); it('returns 401 when code is wrong', async () => { const { secret } = freshSecret(); await request(app).post('/api/totp/setup').send({ secret }); const res = await request(app).post('/api/totp/verify-setup').send({ code: '000000' }); expect(res.status).toBe(401); expect(res.body.error).toMatch(/DC-111/); }); it('returns 200 + activates TOTP + creates session on valid code', async () => { const { secret, token } = freshSecret(); await request(app).post('/api/totp/setup').send({ secret }); const res = await request(app).post('/api/totp/verify-setup').send({ code: token }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.message).toMatch(/TOTP enabled successfully/); // TOTP config activated + persisted expect(deps.totpConfig.isSetUp).toBe(true); expect(deps.totpConfig.enabled).toBe(true); expect(deps.saveTotpConfig).toHaveBeenCalled(); // pending_secret → totp.secret promotion, pending cleared expect(await deps.credentialManager.retrieve('totp.secret')).toBe(secret); expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull(); // Session established expect(deps.session.create).toHaveBeenCalled(); expect(deps.session.setCookie).toHaveBeenCalled(); // Note: renewCSRFToken is only called on /totp/verify (login), not /totp/verify-setup }); }); // ──────────────────────────────────────────────────────────────────── // POST /api/totp/verify (login flow — TOTP already configured) // ──────────────────────────────────────────────────────────────────── describe('POST /api/totp/verify (login)', () => { async function setupTOTP() { const { secret, token } = freshSecret(); await request(app).post('/api/totp/setup').send({ secret }); await request(app).post('/api/totp/verify-setup').send({ code: token }); // Reset mocks but keep config/secret state for the test jest.clearAllMocks(); return secret; } it('returns 400 when code is missing', async () => { const res = await request(app).post('/api/totp/verify').send({}); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Invalid code format/); }); it('returns 400 when TOTP is not enabled', async () => { const res = await request(app).post('/api/totp/verify').send({ code: '123456' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/TOTP is not enabled/); }); it('returns 401 when code is wrong (TOTP active)', async () => { await setupTOTP(); const res = await request(app).post('/api/totp/verify').send({ code: '000000' }); expect(res.status).toBe(401); expect(res.body.error).toMatch(/DC-111/); }); it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => { const secret = await setupTOTP(); const token = authenticator.generate(secret); const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.message).toMatch(/Authenticated successfully/); expect(res.body.csrfToken).toBe('mock-csrf-token'); expect(res.body.ssoToken).toBe('mock-sso-handoff-token'); expect(deps.session.create).toHaveBeenCalled(); expect(deps.session.setCookie).toHaveBeenCalled(); expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1); expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami'); expect(deps.renewCSRFToken).toHaveBeenCalled(); }); it('does not issue an unbound handoff token for a dashboard-only login', async () => { const secret = await setupTOTP(); const token = authenticator.generate(secret); const res = await request(app).post('/api/totp/verify').send({ code: token }); expect(res.status).toBe(200); expect(res.body.ssoToken).toBeNull(); expect(deps.session.createHandoffToken).not.toHaveBeenCalled(); }); it('rejects an invalid handoff service ID before issuing a token', async () => { const secret = await setupTOTP(); const token = authenticator.generate(secret); const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Invalid service ID/); expect(deps.session.createHandoffToken).not.toHaveBeenCalled(); }); }); // ──────────────────────────────────────────────────────────────────── // GET /api/totp/check-session (the auth gate Caddy calls) // ──────────────────────────────────────────────────────────────────── describe('GET /api/totp/check-session', () => { it('returns 401 when TOTP is not enabled (passthrough removed for security)', async () => { // SECURITY FIX (EDIT 2): unconditional bypass was removed. Without a // valid session, /totp/check-session must always reject — even when TOTP // is disabled or sessionDuration is "never". const res = await request(app).get('/api/totp/check-session'); expect(res.status).toBe(401); expect(res.body.error).toMatch(/TOTP protection required|session/i); }); it('returns 401 when sessionDuration is "never" and no session exists (passthrough removed for security)', async () => { deps.totpConfig.enabled = true; deps.totpConfig.isSetUp = true; deps.totpConfig.sessionDuration = 'never'; const res = await request(app).get('/api/totp/check-session'); expect(res.status).toBe(401); }); it('returns 401 when no session exists (BACKLOG: "no token → 401")', async () => { deps.totpConfig.enabled = true; deps.totpConfig.isSetUp = true; deps.totpConfig.sessionDuration = '24h'; // session.isValid returns false because sessionStore is empty const res = await request(app).get('/api/totp/check-session'); expect(res.status).toBe(401); expect(res.body.error).toMatch(/Session expired or invalid/); // Cache-control headers must be set to avoid Caddy auth loops expect(res.headers['cache-control']).toMatch(/no-store/); }); it('returns 200 { authenticated: true } when session is valid (BACKLOG: "authenticated request succeeds")', async () => { deps.totpConfig.enabled = true; deps.totpConfig.isSetUp = true; deps.totpConfig.sessionDuration = '24h'; // Pre-populate the session store as if verify already ran deps.session._grantSession('127.0.0.1'); const res = await request(app).get('/api/totp/check-session').set('X-Forwarded-For', '127.0.0.1'); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, authenticated: true }); }); }); // ──────────────────────────────────────────────────────────────────── // POST /api/totp/disable // ──────────────────────────────────────────────────────────────────── describe('POST /api/totp/disable', () => { async function setupTOTP() { const { secret, token } = freshSecret(); await request(app).post('/api/totp/setup').send({ secret }); await request(app).post('/api/totp/verify-setup').send({ code: token }); jest.clearAllMocks(); return secret; } it('returns 400 when TOTP is active but no code is provided', async () => { await setupTOTP(); const res = await request(app).post('/api/totp/disable').send({}); expect(res.status).toBe(400); expect(res.body.error).toMatch(/valid TOTP code is required/); }); it('returns 401 when code is wrong', async () => { await setupTOTP(); const res = await request(app).post('/api/totp/disable').send({ code: '000000' }); expect(res.status).toBe(401); expect(res.body.error).toMatch(/DC-111/); }); it('returns 200 + clears TOTP state on valid code', async () => { const secret = await setupTOTP(); const code = authenticator.generate(secret); const res = await request(app).post('/api/totp/disable').send({ code }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); // TOTP disabled, secrets cleared, session cleared expect(deps.totpConfig.enabled).toBe(false); expect(deps.totpConfig.isSetUp).toBe(false); expect(deps.totpConfig.sessionDuration).toBe('never'); expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull(); expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull(); expect(deps.session.clear).toHaveBeenCalled(); expect(deps.session.clearCookie).toHaveBeenCalled(); expect(deps.saveTotpConfig).toHaveBeenCalled(); }); }); // ──────────────────────────────────────────────────────────────────── // POST /api/totp/config (session duration change) // ──────────────────────────────────────────────────────────────────── describe('POST /api/totp/config (update settings)', () => { it('updates sessionDuration with a valid value', async () => { const res = await request(app).post('/api/totp/config').send({ sessionDuration: '7d' }); expect(res.status).toBe(200); expect(res.body.config.sessionDuration).toBe('7d'); expect(deps.saveTotpConfig).toHaveBeenCalled(); }); it('rejects an invalid sessionDuration', async () => { const res = await request(app).post('/api/totp/config').send({ sessionDuration: '99y' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Invalid session duration/); }); it('setting sessionDuration to "never" disables TOTP', async () => { deps.totpConfig.enabled = true; deps.totpConfig.isSetUp = true; const res = await request(app).post('/api/totp/config').send({ sessionDuration: 'never' }); expect(res.status).toBe(200); expect(deps.totpConfig.sessionDuration).toBe('never'); expect(deps.totpConfig.enabled).toBe(false); }); }); // ──────────────────────────────────────────────────────────────────── // End-to-end flow (BACKLOG: "Cover the full /api/auth/check → session → endpoint flow") // ──────────────────────────────────────────────────────────────────── describe('End-to-end: setup → login → check-session → disable', () => { it('walks the full BACKLOG DC-006 flow', async () => { // 1. Setup — generate a fresh secret const setupRes = await request(app).post('/api/totp/setup').send({}); expect(setupRes.status).toBe(200); const secret = setupRes.body.manualKey; const setupCode = authenticator.generate(secret); // 2. Verify-setup — activate TOTP const verifySetupRes = await request(app).post('/api/totp/verify-setup').send({ code: setupCode }); expect(verifySetupRes.status).toBe(200); expect(deps.totpConfig.isSetUp).toBe(true); // 3. Simulate session expiry by clearing the store deps.session.ipSessions.clear(); // 4. Re-login via /totp/verify (the "login" path) const loginCode = authenticator.generate(secret); const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' }); expect(loginRes.status).toBe(200); expect(loginRes.body.csrfToken).toBeDefined(); expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token'); expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1); // 5. Check-session — should now be authenticated (the BACKLOG "→ endpoint succeeds" step) const checkRes = await request(app).get('/api/totp/check-session'); expect(checkRes.status).toBe(200); expect(checkRes.body).toEqual({ success: true, authenticated: true }); // 6. Logout / disable const disableCode = authenticator.generate(secret); const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode }); expect(disableRes.status).toBe(200); // 7. After disable, check-session deterministically rejects before // checking session validity because TOTP protection is disabled. const afterRes = await request(app).get('/api/totp/check-session'); expect(afterRes.status).toBe(401); }); it('proves otplib is real (not stubbed) by using a totally bogus code', async () => { // Sanity check that the test harness is using real otplib, not a stub. // otplib 12.0.1's authenticator.generate(secret) does not accept a {time} option // (the signature is fixed to current-time TOTP), so a "stale code" test isn't // reproducible across runs. Instead, we verify otplib rejects a code that is // syntactically valid (6 digits) but doesn't match the live TOTP slot. const secret = authenticator.generateSecret(); await request(app).post('/api/totp/setup').send({ secret }); // Generate the real current code, then mutate it — must be rejected const realCode = authenticator.generate(secret); const tampered = realCode === '000000' ? '111111' : '000000'; const res = await request(app).post('/api/totp/verify-setup').send({ code: tampered }); expect(res.status).toBe(401); }); }); }); // ──────────────────────────────────────────────────────────────────── // SECURITY HARDENING — three targeted fixes // (added after the DC-006 integration suite) // ──────────────────────────────────────────────────────────────────── describe('SECURITY: recovery-info auth gate', () => { let app; let deps; beforeEach(() => { jest.clearAllMocks(); ({ app, deps } = createApp()); }); it('rejects unauthenticated requests with 401', async () => { const res = await request(app).get('/api/totp/recovery-info'); expect(res.status).toBe(401); expect(res.body.code).toBe('DC-401'); expect(res.body.error).toMatch(/DC-110/); }); it('allows the request when a valid session exists', async () => { deps.session._grantSession('127.0.0.1'); deps.totpConfig.isSetUp = true; // Stub diagnose to a known shape so we exercise the post-gate logic deps.credentialManager.diagnose = jest.fn(() => Promise.resolve({ status: 'ok' })); const res = await request(app) .get('/api/totp/recovery-info') .set('X-Forwarded-For', '127.0.0.1'); expect(res.status).toBe(200); expect(res.body.status).toBe('healthy'); }); it('explicitly does not leak metadata (isSetUp, hint) without a session', async () => { deps.totpConfig.isSetUp = true; const res = await request(app).get('/api/totp/recovery-info'); expect(res.status).toBe(401); expect(res.body.status).toBeUndefined(); expect(res.body.isSetUp).toBeUndefined(); expect(res.body.hint).toBeUndefined(); }); }); describe('SECURITY: /totp/setup rate limit', () => { let app; let deps; beforeEach(() => { jest.clearAllMocks(); ({ app, deps } = createApp()); }); it('allows the first 3 setup attempts', async () => { for (let i = 0; i < 3; i++) { const res = await request(app) .post('/api/totp/setup') .set('X-Forwarded-For', '10.0.0.1') .send({}); // 200 = success path, anything outside 429 is fine for this assertion expect(res.status).not.toBe(429); expect(res.status).toBe(200); } }); it('rejects the 4th setup attempt from the same IP with 429', async () => { // First 3 succeed for (let i = 0; i < 3; i++) { await request(app) .post('/api/totp/setup') .set('X-Forwarded-For', '10.0.0.2') .send({}); } // 4th hits the rate limit const res = await request(app) .post('/api/totp/setup') .set('X-Forwarded-For', '10.0.0.2') .send({}); expect(res.status).toBe(429); expect(res.body.code).toBe('DC-429'); expect(res.body.error).toMatch(/Too many setup attempts/); }); it('tracks attempts per-IP independently (different IPs each get their own 3)', async () => { // Burn out IP A for (let i = 0; i < 4; i++) { await request(app) .post('/api/totp/setup') .set('X-Forwarded-For', '10.0.0.3') .send({}); } // IP B should still be allowed const resB = await request(app) .post('/api/totp/setup') .set('X-Forwarded-For', '10.0.0.4') .send({}); expect(resB.status).not.toBe(429); expect(resB.status).toBe(200); // IP A is still rate-limited const resA = await request(app) .post('/api/totp/setup') .set('X-Forwarded-For', '10.0.0.3') .send({}); expect(resA.status).toBe(429); }); });