BillTracker/tests/totpService.test.js

97 lines
3.5 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// Phase 1 — totpService is the 2FA core. Pins: a valid TOTP token verifies and a
// wrong one is rejected (raw + through the encrypted-secret path), recovery codes
// are single-use, and login challenges are single-use + expiring.
// (webauthnService full verify needs a real authenticator harness — deferred.)
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const { generateSync } = require('otplib');
const dbPath = path.join(os.tmpdir(), `bill-tracker-totp-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts');
const { encryptSecret } = require('../services/encryptionService.cts');
const totp = require('../services/totpService.cts');
let db, userId;
test.before(() => {
db = getDb();
userId = db
.prepare(
"INSERT INTO users (username, password_hash, role, active) VALUES ('totp-user','x','user',1)",
)
.run().lastInsertRowid;
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) {
try {
fs.rmSync(dbPath + s);
} catch {}
}
});
test('verifyTokenRaw: a current token validates, a wrong one is rejected', () => {
const secret = totp.generateSecret();
const token = generateSync({ secret, type: 'totp' });
assert.equal(totp.verifyTokenRaw(secret, token), true, 'the current token verifies');
assert.equal(totp.verifyTokenRaw(secret, '000000'), false, 'a wrong token is rejected');
assert.equal(totp.verifyTokenRaw(secret, ''), false);
assert.equal(totp.verifyTokenRaw('', token), false);
});
test('verifyToken: works through the encrypted-secret path', () => {
const secret = totp.generateSecret();
const enc = encryptSecret(secret);
const token = generateSync({ secret, type: 'totp' });
assert.equal(totp.verifyToken(enc, token), true);
assert.equal(totp.verifyToken(enc, '000000'), false);
assert.equal(totp.verifyToken(null, token), false, 'no secret → false, no throw');
});
test('recovery codes are single-use', () => {
const codes = ['ABCDE-12345', 'FGHIJ-67890'];
db.prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?').run(
encryptSecret(JSON.stringify(codes.map(totp.hashRecoveryCode))),
userId,
);
const first = totp.consumeRecoveryCode(db, userId, 'ABCDE-12345');
assert.deepEqual(first, { used: true, remaining: 1 }, 'a valid code is consumed');
assert.equal(
totp.consumeRecoveryCode(db, userId, 'ABCDE-12345').used,
false,
'the same code cannot be reused',
);
assert.equal(
totp.consumeRecoveryCode(db, userId, 'ZZZZZ-99999').used,
false,
'an unknown code is rejected',
);
assert.equal(
totp.consumeRecoveryCode(db, userId, 'fghij67890').used,
true,
'formatting/spacing/case are normalized',
);
});
test('challenges are single-use and reject the unknown/expired', () => {
const id = totp.createChallenge(db, userId);
assert.equal(totp.consumeChallenge(db, id), userId, 'a fresh challenge returns its user');
assert.equal(totp.consumeChallenge(db, id), null, 'the same challenge cannot be consumed twice');
assert.equal(totp.consumeChallenge(db, 'no-such-challenge'), null);
// expired challenge is not honoured
db.prepare('INSERT INTO totp_challenges (id, user_id, expires_at) VALUES (?, ?, ?)').run(
'expired-ch',
userId,
'2000-01-01 00:00:00',
);
assert.equal(totp.consumeChallenge(db, 'expired-ch'), null, 'expired challenge rejected');
});