import { OtpService, normalizeOtpTarget } from './otp.service'; describe('normalizeOtpTarget', () => { it('canonicalises Ethiopian forms to one E.164 key', () => { const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099']; const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone); expect(new Set(keys)).toEqual(new Set(['+251986680099'])); }); it('maps local 07… mobile to +2517…', () => { expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678'); }); it('canonicalises email case and surrounding whitespace to one key', () => { const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM']; const keys = forms.map((email) => normalizeOtpTarget({ email }).email); expect(new Set(keys)).toEqual(new Set(['a@b.com'])); }); it('keeps an already-normalised email stable (idempotent)', () => { const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!; expect(normalizeOtpTarget({ email: once }).email).toBe(once); }); it('keeps an already-normalised number stable (idempotent)', () => { const once = normalizeOtpTarget({ phone: '0986680099' }).phone!; expect(normalizeOtpTarget({ phone: once }).phone).toBe(once); }); }); interface FakeRow { id: string; phone?: string; email?: string; otp: string; updatedAt: Date; } /** * In-memory stand-in for OtpRepository, mirroring the two properties the service * depends on: rows are matched by OR across every channel named, and a send * replaces all overlapping rows with one row carrying every channel. */ function makeService( transports: { sms?: () => Promise<{ queued: boolean }>; email?: () => Promise<{ queued: boolean }>; } = {}, ) { let rows: FakeRow[] = []; let nextId = 1; const matches = (row: FakeRow, t: { phone?: string; email?: string }) => (!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone); const repo = { findByTarget: jest.fn( async (t: { phone?: string; email?: string }) => rows.filter((row) => matches(row, t))[0] ?? null, ), replaceOtp: jest.fn( async (t: { phone?: string; email?: string }, otp: string) => { const overlapping = rows.filter((row) => matches(row, t)); rows = rows.filter((row) => !overlapping.includes(row)); const record: FakeRow = { id: String(nextId++), ...t, otp, updatedAt: new Date(), }; rows.push(record); return { record, rotated: overlapping.length > 0 }; }, ), deleteOtp: jest.fn(async (row: FakeRow) => { rows = rows.filter((r) => r !== row); }), }; // Both clients return `{ queued }` — the service reads it to tell a published // code apart from one the transport silently dropped. const sms = { sendSms: jest.fn(transports.sms ?? (async () => ({ queued: true }))), }; const email = { sendEmail: jest.fn(transports.email ?? (async () => ({ queued: true }))), }; const service = new OtpService(repo as never, sms as never, email as never); return { service, sms, email, rows: () => rows }; } describe('OtpService — send/verify agree across phone formats', () => { it('verifies a code sent to +251… when verify is called with 09…', async () => { const { service, rows } = makeService(); await service.sendOtp({ phone: '+251986680099' }); await expect( service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => { const { service, rows } = makeService(); await service.sendOtp({ email: ' User@Example.COM ' }); await expect( service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); }); describe('OtpService — dual-channel send', () => { const both = { phone: '0986680099', email: 'User@Example.COM' }; it('sends ONE code to both transports', async () => { const { service, sms, email, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; expect(sms.sendSms).toHaveBeenCalledTimes(1); expect(email.sendEmail).toHaveBeenCalledTimes(1); // Same secret on both messages — the user types whichever arrives first. expect(sms.sendSms).toHaveBeenCalledWith( expect.objectContaining({ to: '+251986680099', message: expect.stringContaining(otp), }), ); expect(email.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ to: 'user@example.com', text: expect.stringContaining(otp), }), ); // One row, both channels canonicalised. expect(rows()).toHaveLength(1); expect(rows()[0]).toMatchObject({ phone: '+251986680099', email: 'user@example.com', }); }); it.each([ ['phone alone', { phone: '0986680099' }], ['email alone', { email: 'user@example.com' }], ['both', both], ])('verifies a dual-channel code when quoted back by %s', async (_label, target) => { const { service, rows } = makeService(); await service.sendOtp(both); await expect( service.verifyOtpForAction(target, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); it('consuming the code via one channel kills the other', async () => { const { service, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; await service.verifyOtpForAction({ email: 'user@example.com' }, otp); // Single-use is per-code, not per-channel: the phone half must be dead too. await expect( service.verifyOtpForAction({ phone: '0986680099' }, otp), ).rejects.toThrow(/No verification code was requested/); }); it('replaces an overlapping single-channel row instead of colliding with it', async () => { const { service, rows } = makeService(); // A pending signup code on the phone only, then a dual-channel send. await service.sendOtp({ phone: '0986680099' }); await service.sendOtp(both); expect(rows()).toHaveLength(1); expect(rows()[0]).toMatchObject({ email: 'user@example.com' }); }); it('degrades to one channel when the account has only one contact', async () => { const { service, sms, email } = makeService(); await service.sendOtp({ phone: '0986680099' }); expect(sms.sendSms).toHaveBeenCalledTimes(1); expect(email.sendEmail).not.toHaveBeenCalled(); }); it('still succeeds when one transport throws', async () => { const { service, rows } = makeService({ sms: async () => { throw new Error('broker down'); }, }); await expect(service.sendOtp(both)).resolves.toMatchObject({ success: true, delivered: true, }); // The code is live and verifiable on the channel that worked. await expect( service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); it('fails the request when every transport throws', async () => { const { service } = makeService({ sms: async () => { throw new Error('broker down'); }, email: async () => { throw new Error('broker down'); }, }); await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP'); }); it('shares one brute-force budget across both channels', async () => { const { service, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; // Alternating channels must not hand the attacker two independent budgets: // 5 wrong guesses in total burn the code regardless of how they are split. for (const target of [ { phone: '0986680099' }, { email: 'user@example.com' }, { phone: '0986680099' }, { email: 'user@example.com' }, ]) { await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow( 'Invalid verification code', ); } await expect( service.verifyOtpForAction({ email: 'user@example.com' }, '000000'), ).rejects.toThrow(/Too many incorrect attempts/); // Burned: even the correct code no longer works. await expect(service.verifyOtpForAction(both, otp)).rejects.toThrow( /No verification code was requested/, ); }); });