// TOTP tests (js/app.totp.js) — cross-checked against the RFC 6238 Appendix B // reference vectors. generateTOTP reads Date.now() internally, so each case // stubs the sandbox clock to the vector's fixed time. // // RFC 6238 secret = ASCII "12345678901234567890" (20 bytes) → base32 // "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ". Appendix B lists 8-digit SHA-1 codes; // generateTOTP defaults to 6 digits, so the expectations are the last 6. const test = require('node:test'); const assert = require('node:assert/strict'); const { loadApp } = require('./harness.js'); const ctx = loadApp(); const T = ctx.__test; const RFC_SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; // Run generateTOTP as if the wall clock were `unixSec`. node --test isolates // each test file in its own process, so mutating the shared Date here can't // leak into other suites; we still restore to be tidy. async function codeAt(unixSec, period = 30, digits = 6) { const orig = ctx.Date.now; ctx.Date.now = () => unixSec * 1000; try { return await T.generateTOTP(RFC_SECRET, period, digits); } finally { ctx.Date.now = orig; } } test('base32Decode: RFC 6238 secret decodes to ASCII "12345678901234567890"', () => { const bytes = T.base32Decode(RFC_SECRET); assert.equal(new TextDecoder().decode(bytes), '12345678901234567890'); }); test('base32Decode: tolerates lowercase, spaces and padding', () => { const a = T.base32Decode('JBSWY3DPEHPK3PXP'); const b = T.base32Decode('jbsw y3dp ehpk 3pxp=='); assert.equal(T.bytesToHex(a), T.bytesToHex(b)); }); test('base32Decode: throws on an invalid character', () => { assert.throws(() => T.base32Decode('JBSW0189'), /Invalid base32/); // 0/1/8/9 not in alphabet }); // RFC 6238 Appendix B (SHA-1), 8-digit → last 6 digits for our 6-digit default. const VECTORS = [ [59, '287082'], [1111111109, '081804'], [1111111111, '050471'], [1234567890, '005924'], [2000000000, '279037'], ]; for (const [t, expected] of VECTORS) { test(`generateTOTP: RFC 6238 vector at t=${t} → ${expected}`, async () => { const r = await codeAt(t); assert.equal(r.code, expected); assert.equal(r.period, 30); }); } test('generateTOTP: secondsLeft counts down within the 30s window', async () => { // t=45 → 15s into the second window → 15 left. const r = await codeAt(45); assert.equal(r.secondsLeft, 15); // t=59 → 29s into the window → 1 left. assert.equal((await codeAt(59)).secondsLeft, 1); }); test('generateTOTP: same window → same code (deterministic per period)', async () => { const a = await codeAt(1234567890); const b = await codeAt(1234567890 + 5); // still the same 30s window assert.equal(a.code, b.code); }); test('parseOtpAuthUri: extracts the secret from an otpauth:// URI', () => { assert.equal( T.parseOtpAuthUri('otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example'), 'JBSWY3DPEHPK3PXP'); }); test('parseOtpAuthUri: returns null for non-otpauth input', () => { assert.equal(T.parseOtpAuthUri('https://example.com'), null); assert.equal(T.parseOtpAuthUri('JBSWY3DPEHPK3PXP'), null); assert.equal(T.parseOtpAuthUri(''), null); }); test('parseOtpAuthUri → generateTOTP: parsed secret produces a valid 6-digit code', async () => { const secret = T.parseOtpAuthUri('otpauth://totp/x?secret=' + RFC_SECRET); const orig = ctx.Date.now; ctx.Date.now = () => 59 * 1000; try { const r = await T.generateTOTP(secret, 30, 6); assert.equal(r.code, '287082'); } finally { ctx.Date.now = orig; } });