import { test } from 'node:test'; import assert from 'node:assert/strict'; import { base32Encode, base32Decode, generateSecret, generateToken, verifyToken, otpauthURI, generateRecoveryCodes, } from '../../src/auth/totp.js'; // ── base32 round-trips ────────────────────────────────────────────────────── test('base32 encode/decode round-trips arbitrary bytes', () => { for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) { const buf = Buffer.from(s); assert.deepEqual(base32Decode(base32Encode(buf)), buf); } }); // ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the // low 6 here, so compare the last 6 digits of each published value). ────────── // The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and // check the 6-digit code at each published timestamp. test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => { const secret = base32Encode(Buffer.from('12345678901234567890')); // [unix seconds, full 8-digit code from the RFC] → expect last 6 digits. const vectors = [ [59, '94287082'], [1111111109, '07081804'], [1111111111, '14050471'], [1234567890, '89005924'], [2000000000, '69279037'], [20000000000, '65353130'], ]; for (const [secs, full8] of vectors) { const got = generateToken(secret, secs * 1000); assert.equal(got, full8.slice(-6), `t=${secs}`); } }); // ── verify with drift window ──────────────────────────────────────────────── test('verifyToken accepts the current code and ±1 step of drift', () => { const secret = generateSecret(); const now = 1_700_000_000_000; const code = generateToken(secret, now); assert.equal(verifyToken(secret, code, now), true); // 30s earlier / later still inside ±1 window. assert.equal(verifyToken(secret, code, now + 30_000), true); assert.equal(verifyToken(secret, code, now - 30_000), true); // 2 steps away → rejected. assert.equal(verifyToken(secret, code, now + 90_000), false); }); test('verifyToken rejects malformed / empty input without throwing', () => { const secret = generateSecret(); assert.equal(verifyToken(secret, ''), false); assert.equal(verifyToken(secret, 'abcdef'), false); assert.equal(verifyToken(secret, '12345'), false); // too short assert.equal(verifyToken(secret, '1234567'), false); // too long assert.equal(verifyToken('', '123456'), false); }); test('verifyToken tolerates spaces in the user-entered code', () => { const secret = generateSecret(); const now = 1_700_000_000_000; const code = generateToken(secret, now); assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), true); }); // ── otpauth URI ───────────────────────────────────────────────────────────── test('otpauthURI embeds secret, issuer, and account', () => { const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight'); assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/); assert.match(uri, /secret=JBSWY3DPEHPK3PXP/); assert.match(uri, /issuer=Dragonflight/); assert.match(uri, /digits=6/); assert.match(uri, /period=30/); }); // ── recovery codes ────────────────────────────────────────────────────────── test('generateRecoveryCodes returns N distinct formatted codes', () => { const codes = generateRecoveryCodes(10); assert.equal(codes.length, 10); assert.equal(new Set(codes).size, 10); for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/); });