diff --git a/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js b/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js new file mode 100644 index 0000000..a5d93fa --- /dev/null +++ b/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js @@ -0,0 +1,483 @@ +/** + * 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 + * - check-session with valid session → 200 { 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(), + 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, + ...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 }); + 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(deps.session.create).toHaveBeenCalled(); + expect(deps.session.setCookie).toHaveBeenCalled(); + expect(deps.renewCSRFToken).toHaveBeenCalled(); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // GET /api/totp/check-session (the auth gate Caddy calls) + // ──────────────────────────────────────────────────────────────────── + describe('GET /api/totp/check-session', () => { + it('always returns 200 when TOTP is not enabled (passthrough)', async () => { + const res = await request(app).get('/api/totp/check-session'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authenticated: true }); + }); + + it('always returns 200 when sessionDuration is "never" (passthrough)', 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(200); + expect(res.body).toEqual({ authenticated: true }); + }); + + 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({ 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 }); + expect(loginRes.status).toBe(200); + expect(loginRes.body.csrfToken).toBeDefined(); + + // 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({ 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 should be passthrough (TOTP off) + const afterRes = await request(app).get('/api/totp/check-session'); + expect(afterRes.status).toBe(200); + expect(afterRes.body).toEqual({ authenticated: true }); + }); + + 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); + }); + }); +}); diff --git a/dashcaddy-api/routes/auth/totp.js b/dashcaddy-api/routes/auth/totp.js index f9af335..ccf62bd 100644 --- a/dashcaddy-api/routes/auth/totp.js +++ b/dashcaddy-api/routes/auth/totp.js @@ -1,6 +1,6 @@ const express = require('express'); -const { ValidationError, AuthenticationError } = require('../../../src/utilities/errors'); -const { ok, successMessage } = require('../src/utils/responses'); +const { ValidationError, AuthenticationError } = require('../../src/utilities/errors'); +const { ok, successMessage } = require('../../src/utils/responses'); /** * Auth TOTP routes factory