screentinker/server/test/totp-unit.test.js
ScreenTinker 728f03beba test(server): TOTP - bite, lockout, replay, recovery, st_ bypass, key-rotation (#100)
Unit: the mfa_pending BITE (db-injected so removing the rejection goes red), lockout, replay,
recovery-hash, decrypt-null graceful. Integration: enrollment, login->mfa_required, route-level
bite, recovery single-use, API-token bypass, verify lockout. Key-rotation: enroll under key A,
reboot under key B -> recovery still works, TOTP fails cleanly (no 500).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00

86 lines
4.7 KiB
JavaScript

'use strict';
// #100 unit tests: the security-critical assertions that don't need a full server.
// The bite-test (#1) injects an in-memory db with a real user row so that REMOVING the
// mfa_pending rejection in requireAuth makes it go red (the pending token would then
// find the user and call next()).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const { authenticator } = require('otplib');
// Inject the db BEFORE requiring middleware/auth so requireAuth queries this one.
const mem = new Database(':memory:');
mem.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, name TEXT, role TEXT,
auth_provider TEXT, avatar_url TEXT, plan_id TEXT, email_alerts INTEGER,
must_change_password INTEGER NOT NULL DEFAULT 0)`);
mem.prepare("INSERT INTO users (id,email,name,role,auth_provider) VALUES ('u1','u1@x','U1','user','local')").run();
require.cache[require.resolve('../db/database')] = {
id: require.resolve('../db/database'), loaded: true, exports: { db: mem },
};
const { requireAuth, generateMfaPendingToken, generateToken } = require('../middleware/auth');
const totp = require('../lib/totp');
const totpLockout = require('../lib/totp-lockout');
function runRequireAuth(token) {
const req = { headers: { authorization: 'Bearer ' + token }, originalUrl: '/api/devices' };
let status = 200, nexted = false;
const res = { status(s) { status = s; return this; }, json() { return this; } };
requireAuth(req, res, () => { nexted = true; });
return { status, nexted, req };
}
test('#100 BITE: requireAuth rejects an mfa_pending token (no password-only session)', () => {
const pending = runRequireAuth(generateMfaPendingToken({ id: 'u1' }));
assert.equal(pending.status, 401, 'mfa_pending token must be 401');
assert.equal(pending.nexted, false, 'must NOT call next() for an mfa_pending token');
// Contrast: a FULL token for the SAME user passes - so the user EXISTS in the db,
// which means removing the mfa_pending check would let the pending token through too
// (next() called). That's what makes this a real bite-test, not a vacuous 401.
const full = runRequireAuth(generateToken({ id: 'u1', email: 'u1@x', role: 'user' }, null));
assert.equal(full.nexted, true, 'a full token for the same user must pass requireAuth');
assert.equal(full.req.user.id, 'u1');
});
test('#100 lockout: locks after MAX_FAILS, lifts after the window, reset clears', () => {
const k = 'user-' + crypto.randomUUID();
for (let i = 0; i < totpLockout.MAX_FAILS - 1; i++) totpLockout.recordFailure(k, 1000);
assert.equal(totpLockout.isLocked(k, 1000), false);
totpLockout.recordFailure(k, 1000);
assert.equal(totpLockout.isLocked(k, 1000), true, 'locked at MAX_FAILS');
assert.equal(totpLockout.isLocked(k, 1000 + totpLockout.LOCKOUT_MS + 1), false, 'lifts after window');
totpLockout.reset(k);
assert.equal(totpLockout.isLocked(k, 1000), false, 'reset clears');
});
test('#100 replay: a TOTP code from an already-consumed step is rejected', () => {
const secret = totp.generateSecret();
const code = authenticator.generate(secret);
const step = totp.currentStep();
assert.equal(totp.verifyCode(code, secret, step - 1), step, 'fresh code accepted, returns the step');
assert.equal(totp.verifyCode(code, secret, step), null, 'same code at the consumed step is blocked');
});
test('#100 key-mismatch is graceful: decrypt failure -> null (no throw); verifyCode tolerates null', () => {
// If the secretbox key changes (rotated JWT_SECRET, non-persisted .jwt_secret), the
// stored TOTP secret becomes undecryptable. That must degrade, not 500.
assert.equal(totp.decryptSecret('!!!not-decryptable'), null, 'undecryptable -> null, not a throw');
assert.doesNotThrow(() => totp.verifyCode('123456', null, 0), 'null secret must not throw on the login path');
assert.equal(totp.verifyCode('123456', null, 0), null, 'null secret -> null (recovery path then handles it)');
});
test('#100 recovery codes: stored hashed, never plaintext; input normalized', () => {
const { plain, hashes } = totp.generateRecoveryCodes(10);
assert.equal(plain.length, 10);
assert.equal(hashes.length, 10);
assert.match(plain[0], /^[0-9A-F]{10}$/, 'plaintext is 10 hex chars (shown once)');
assert.match(hashes[0], /^[0-9a-f]{64}$/, 'stored value is a SHA-256 hash');
assert.notEqual(plain[0], hashes[0], 'the stored value is not the plaintext');
// typed with stray spaces/hyphens/lowercase still matches the stored hash
const messy = ' ' + plain[0].toLowerCase().slice(0, 5) + '-' + plain[0].toLowerCase().slice(5) + ' ';
assert.equal(totp.hashRecoveryCode(messy), hashes[0], 'normalized input matches');
});