DC-006: Add integration test for TOTP auth flow
Covers the full BACKLOG DC-006 acceptance criteria: - GET /api/totp/config — read current config - POST /api/totp/setup — generate / import Base32 secret - POST /api/totp/verify-setup — activate TOTP after setup - POST /api/totp/verify — login with TOTP code → session + CSRF - GET /api/totp/check-session — auth gate (200 / 401) - POST /api/totp/disable — disable TOTP (requires valid code) - POST /api/totp/config — update session duration 25 tests, all passing. Uses real otplib for code generation (so we exercise actual TOTP math) but mocks credentialManager, session, totpConfig, saveTotpConfig — those own their own state machines (disk, cookies, file) that don't belong in a routes test. Also fixed a latent DC-005 bug: routes/auth/totp.js had wrong require-path depth after the refactor (../../../src/... went 3 levels up instead of 2, breaking route load). Changed to ../../src/... for the 2-level depth. NOTE: the same depth bug exists in many other depth-2 route files (auth/keys.js, auth/sso-gate.js, auth/session-handlers.js, recipes/*, apps/*, arr/*, config/*) — see BACKLOG.md DC-005 follow-up note. Tests didn't catch this because no test previously imported the auth routes; this new test exercises that import path. Result: 904/904 Jest tests pass (879 baseline + 25 new). ESLint: this file clean. Pre-existing 134 src/ warnings are unrelated (DC-005 refactor moved files without re-applying DC-004 lint cleanup — separate follow-up).
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ValidationError, AuthenticationError } = require('../../../src/utilities/errors');
|
const { ValidationError, AuthenticationError } = require('../../src/utilities/errors');
|
||||||
const { ok, successMessage } = require('../src/utils/responses');
|
const { ok, successMessage } = require('../../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth TOTP routes factory
|
* Auth TOTP routes factory
|
||||||
|
|||||||
Reference in New Issue
Block a user