slice 2a COMPLETE: TOTP 2FA (enable2fa/confirm2fa/login-2fa + protectedProcedure)

totp.ts + backup-codes.ts produced by ornith (remote model, reviewed+approved);
router/context/schema integration by orchestrator (delicate edit-existing — ornith
timed out on a dispatch of that size in-session). 58/58 backend tests green.
2FA flow: enable2fa -> confirm2fa -> login now requires a valid TOTP.
This commit is contained in:
Investor Flow Build
2026-06-29 19:32:09 -04:00
parent 9df05ade71
commit 89bdf0edc4
3 changed files with 67 additions and 4 deletions
@@ -100,3 +100,30 @@ test('market.snapshot serves cached values (not stale) after drain populates cac
assert.equal(snap.stale.candles, false);
assert.equal(snap.stale.sector, false);
});
// --- Slice 2: 2FA flow ---
import { totp as computeTotp } from '../../auth/totp.ts';
test('2FA: enable2fa -> confirm2fa -> login requires totp', async () => {
const { freshCtx } = setup();
// signup
const signupCtx = freshCtx();
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
// enable2fa (protected: set ctx.userId)
const eCtx = freshCtx(); eCtx.userId = userId;
const en = await appRouter.createCaller(eCtx).auth.enable2fa({});
assert.ok(en.totpSecret);
assert.ok(en.qrUrl.startsWith('otpauth://TOTP/'));
assert.equal(en.backupCodes.length, 10);
// confirm2fa with a valid code
const code = computeTotp(en.totpSecret);
const cCtx = freshCtx(); cCtx.userId = userId;
const c = await appRouter.createCaller(cCtx).auth.confirm2fa({ totp: code });
assert.equal(c.ok, true);
// login WITHOUT totp now fails
await assert.rejects(() => appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123' }), (e: { code: string }) => e.code === 'UNAUTHORIZED');
// login WITH valid totp succeeds
const code2 = computeTotp(en.totpSecret);
const res = await appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123', totp: code2 });
assert.equal(res.userId, userId);
});