2026-07-05 13:05:34 -05:00
|
|
|
'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;
|
|
|
|
|
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 11:34:08 -05:00
|
|
|
const { getDb, closeDb } = require('../db/database.cts');
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 10:35:29 -05:00
|
|
|
const { encryptSecret } = require('../services/encryptionService.cts');
|
|
|
|
|
const totp = require('../services/totpService.cts');
|
2026-07-05 13:05:34 -05:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|